1 module fluid.file_input;
2 
3 // To consider: Split into two modules, this plus generic text input with suggestions.
4 
5 import std.conv;
6 import std.file;
7 import std.path;
8 import std.range;
9 import std.string;
10 import std.typecons;
11 import std.algorithm;
12 
13 import fluid.text;
14 import fluid.space;
15 import fluid.label;
16 import fluid.utils;
17 import fluid.input;
18 import fluid.style;
19 import fluid.backend;
20 import fluid.button;
21 import fluid.structs;
22 import fluid.text_input;
23 import fluid.popup_frame;
24 
25 
26 @safe:
27 
28 // TODO
29 version (none):
30 
31 
32 /// A file picker node.
33 ///
34 /// Note, this node is hidden by default, use `show` or `spawnPopup` to display.
35 alias fileInput = simpleConstructor!FileInput;
36 
37 /// ditto
38 class FileInput : PopupFrame {
39 
40     // TODO maybe create a generic "search all" component? Maybe something that could automatically collect all
41     //      button data?
42 
43     mixin enableInputActions;
44 
45     /// Callback to run when input was cancelled.
46     void delegate() cancelled;
47 
48     /// Callback to run when input was submitted.
49     void delegate() submitted;
50 
51     /// Max amount of suggestions that can be provided.
52     size_t suggestionLimit = 10;
53 
54     private {
55 
56         /// Last saved focus state.
57         ///
58         /// Used to cancel when the focus is lost and to autofocus when opened.
59         bool savedFocus;
60 
61         /// Label with the title of the file picker.
62         Label titleLabel;
63 
64         /// Text input field containing the currently selected directory or file for the file picker.
65         FilenameInput input;
66 
67         /// Space for all suggestions.
68         ///
69         /// Starts empty, and is filled in as suggestions appear. Buttons are reused, so no more buttons will be
70         /// allocated once the suggestion limit is reached. Buttons are hidden if they don't contain any relevant
71         /// suggestions.
72         Space suggestions;
73 
74         /// Number of available suggestions.
75         int suggestionCount;
76 
77         /// Filename typed by the user, before choosing suggestions.
78         char[] typedFilename;
79 
80         /// Currently chosen suggestion. 0 is no suggestion chosen.
81         size_t currentSuggestion;
82 
83     }
84 
85     /// Create a file picker.
86     ///
87     /// Note: This is an "overlay" node, so it's expected to be placed in a global `onionFrame`. The node assigns its
88     /// own default layout, which typically shouldn't be overriden.
89     this(string name, void delegate() @trusted submitted, void delegate() @trusted cancelled = null) {
90 
91         super(
92             titleLabel  = label(name),
93             input       = new FilenameInput("Path to file...", submitted),
94             suggestions = vspace(.layout!"fill"),
95         );
96 
97         this.layout = .layout(1, NodeAlign.center, NodeAlign.start);
98         this.cancelled = cancelled;
99         this.submitted = submitted;
100 
101         // Hide the node
102         hide();
103 
104         // Windows is silly
105         version (Windows) input.value = `C:\`.dup;
106         else input.value = expandTilde("~/").dup;
107 
108         typedFilename = input.value;
109 
110         // Load suggestions
111         addSuggestions();
112 
113         // Bind events
114         input.changed = () {
115 
116             // Trigger an event
117             if (changed) changed();
118 
119             // Update suggestions
120             typedFilename = input.value;
121             currentSuggestion = 0;
122             updateSuggestions();
123 
124         };
125 
126         input.submitted = () {
127 
128             // Suggestion checked
129             if (currentSuggestion != 0) {
130 
131                 reload();
132 
133                 // Restore focus
134                 focus();
135 
136             }
137 
138             // Final submit
139             else submit();
140 
141         };
142 
143     }
144 
145     ref inout(Text!Label) text() inout {
146 
147         return titleLabel.text;
148 
149     }
150 
151     inout(char[]) value() inout {
152 
153         return input.value;
154 
155     }
156 
157     char[] value(char[] newValue) {
158 
159         updateSize();
160         return typedFilename = input.value = newValue;
161 
162     }
163 
164     protected class FilenameInput : TextInput {
165 
166         mixin enableInputActions;
167 
168         this(T...)(T args) {
169 
170             super(args);
171 
172         }
173 
174         @(FluidInputAction.entryUp)
175         protected void entryUp() {
176 
177             typedFilename = input.value = input.value.dirName ~ "/";
178             updateSuggestions();
179 
180         }
181 
182         @(FluidInputAction.cancel)
183         protected void cancel() {
184 
185             cancel();
186 
187         }
188 
189         @(FluidInputAction.entryPrevious)
190         protected void entryPrevious() {
191 
192             offsetSuggestion(-1);
193 
194         }
195 
196         @(FluidInputAction.entryNext)
197         protected void entryNext() {
198 
199             offsetSuggestion(+1);
200 
201         }
202 
203     }
204 
205     /// Cancel picking files, triggering `cancelled` event.
206     void cancel() {
207 
208         // Call callback if it exists
209         if (cancelled) cancelled();
210 
211         // Hide
212         hide();
213 
214         savedFocus = false;
215         super.isFocused = false;
216 
217     }
218 
219     /// Clear suggestions
220     void clearSuggestions() {
221 
222         updateSize();
223         suggestionCount = 0;
224 
225         // Hide all suggestion children
226         foreach (child; suggestions.children) {
227 
228             child.hide();
229 
230         }
231 
232     }
233 
234     /// Refresh the suggestion list.
235     void updateSuggestions() {
236 
237         const values = valueTuple;
238         const dir  = values[0];
239         const file = values[1];
240 
241         clearSuggestions();
242 
243         // Check the entries
244         addSuggestions();
245 
246         // Current suggestion was removed
247         if (currentSuggestion > suggestionCount) {
248 
249             currentSuggestion = suggestionCount;
250 
251         }
252 
253     }
254 
255     private void submit() {
256 
257         // Submit the selection
258         if (submitted) submitted();
259 
260         // Remove focus
261         super.isFocused = false;
262         savedFocus = false;
263         hide();
264 
265     }
266 
267     private void addSuggestions() @trusted {
268 
269         const values = valueTuple;
270         const dir  = values[0];
271         const file = values[1];
272 
273         ulong num;
274 
275         // TODO handle errors on a per-entry basis?
276         try foreach (entry; dir.to!string.dirEntries(.text(file, "*"), SpanMode.shallow)) {
277 
278             const name = entry.name.baseName;
279 
280             // Ignore hidden directories if not prompted
281             if (!file.length && name.startsWith(".")) continue;
282 
283             // Stop after reaching the limit
284             if (num++ >= suggestionLimit) break;
285 
286 
287             // Found a directory
288             if (entry.isDir) pushSuggestion(name ~ "/");
289 
290             // File
291             else pushSuggestion(name);
292 
293         }
294 
295         catch (FileException exc) {
296 
297         }
298 
299     }
300 
301     private void pushSuggestion(string text) {
302 
303         const index = suggestionCount;
304 
305         // Ignore if the suggestion limit was reached
306         if (suggestionCount >= suggestionLimit) return;
307 
308         updateSize();
309         suggestionCount += 1;
310 
311         // Check if the suggestion button has been allocated
312         if (index >= suggestions.children.length) {
313 
314             // Create the button
315             suggestions.children ~= new FileInputSuggestion(this, index, text, {
316 
317                 selectSuggestion(index + 1);
318                 reload();
319 
320             });
321 
322         }
323 
324         // Set text for the relevant button
325         else {
326 
327             auto btn = cast(FileInputSuggestion) suggestions.children[index];
328 
329             btn.text = text;
330             btn.show();
331 
332         }
333 
334     }
335 
336     /// Get the value as a (directory, file) tuple.
337     private auto valueTuple() const {
338 
339         return valueTuple(input.value);
340 
341     }
342 
343     /// Ditto.
344     private auto valueTuple(const(char)[] path) const {
345 
346         // Directory
347         if (path.endsWith(dirSeparator)) {
348 
349             return tuple(path, (const(char)[]).init);
350 
351         }
352 
353         const file = path.baseName;
354         return tuple(
355             path.chomp(file),
356             file,
357         );
358 
359     }
360 
361     // TODO perhaps some of these should be exposed as API.
362 
363     /// Reload the suggestions using user input.
364     private void reload() {
365 
366         typedFilename = input.value;
367         currentSuggestion = 0;
368         updateSuggestions();
369 
370     }
371 
372     /// Set current suggestion by number.
373     private void selectSuggestion(size_t n) {
374 
375         auto previous = currentSuggestion;
376         currentSuggestion = n;
377 
378         updateSize();
379 
380         // Update input to match suggestion
381         if (currentSuggestion != 0) {
382 
383             auto btn = cast(FileInputSuggestion) suggestions.children[currentSuggestion - 1];
384             auto newValue = to!(char[])(valueTuple(typedFilename)[0] ~ btn.text.value.stripLeft);
385 
386             // Same value, submit
387             if (newValue == input.value) submit();
388 
389             // Update the input
390             else input.value = newValue;
391 
392         }
393 
394         // Nothing selected
395         else {
396 
397             // Restore original text
398             input.value = typedFilename;
399 
400         }
401 
402     }
403 
404     /// Offset currently chosen selection by number.
405     private void offsetSuggestion(size_t n) {
406 
407         const indexLimit = suggestionCount + 1;
408 
409         selectSuggestion((indexLimit + currentSuggestion + n) % indexLimit);
410 
411     }
412 
413     override void focus() {
414 
415         savedFocus = true;
416 
417         // Focus the input instead.
418         input.focus();
419 
420     }
421 
422     override bool isFocused() const {
423 
424         return input.isFocused()
425             || cast(const FileInputSuggestion) tree.focus;
426 
427     }
428 
429     protected override void drawImpl(Rectangle outer, Rectangle inner) @trusted {
430 
431         super.drawImpl(outer, inner);
432 
433         // Wasn't focused
434         if (!savedFocus) {
435 
436             // Focus now
437             focus();
438 
439             // Refresh suggestions
440             updateSuggestions();
441 
442         }
443 
444         // Just lost focus
445         else if (!isFocused) {
446 
447             cancel();
448             return;
449 
450         }
451 
452     }
453 
454     protected override void resizeImpl(Vector2 space) {
455 
456         // Larger windows
457         if (space.x > 600) {
458 
459             // Add margin
460             input.size.x = space.x / 10 + 540;
461 
462         }
463 
464         else input.size.x = space.x;
465 
466         // Resize the node itself
467         super.resizeImpl(space);
468 
469     }
470 
471     protected override void mouseImpl() {
472 
473         input.focus();
474 
475     }
476 
477     // Does nothing
478     protected override bool keyboardImpl() {
479 
480         assert(false, "FileInput cannot directly have focus; call FluidFilePicker.focus to resolve automatically");
481 
482     }
483 
484 }
485 
486 /// Suggestion button for styling.
487 class FileInputSuggestion : Button {
488 
489     mixin enableInputActions;
490 
491     private {
492 
493         int _index;
494         FileInput _input;
495 
496     }
497 
498     private this(FileInput input, int index, string text, void delegate() @safe callback) {
499 
500         super(text, callback);
501         this.layout = .layout!"fill";
502         this._index = index;
503         this._input = input;
504 
505     }
506 
507     /// File input the button belongs to.
508     inout(FileInput) parent() inout {
509         return _input;
510     }
511 
512     /// Index of the button.
513     int index() const {
514         return _index;
515     }
516 
517     /// True if this suggestion is selected.
518     bool isSelected() const {
519         return _input.currentSuggestion == _index+1;
520     }
521 
522 }