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