1 ///
2 module glui.file_picker;
3 
4 // To consider: Split into two modules, this plus generic text input with suggestions.
5 
6 import raylib;
7 import std.conv;
8 import std.file;
9 import std.path;
10 import std.range;
11 import std.string;
12 import std.typecons;
13 import std.algorithm;
14 
15 import glui.frame;
16 import glui.label;
17 import glui.utils;
18 import glui.input;
19 import glui.style;
20 import glui.structs;
21 import glui.rich_label;
22 import glui.text_input;
23 
24 alias filePicker = simpleConstructor!GluiFilePicker;
25 
26 @safe:
27 
28 /// A file picker node.
29 ///
30 /// Note, this node is hidden by default, use `show` to show.
31 /// Styles: $(UL
32 ///     $(LI `selectedStyle` = Style for the currently selected suggestion.)
33 /// )
34 class GluiFilePicker : GluiInput!GluiFrame {
35 
36     mixin DefineStyles!("selectedStyle", q{ Style.init });
37 
38     /// Callback to run when input was cancelled.
39     void delegate() cancelled;
40 
41     /// Max amount of suggestions that can be provided.
42     size_t suggestionLimit = 10;
43 
44     private {
45 
46         /// Last saved focus state.
47         ///
48         /// Used to cancel when the focus is lost and to autofocus when opened.
49         bool savedFocus;
50 
51         /// Label with the title of the file picker.
52         GluiLabel titleLabel;
53 
54         /// Text input field containing the currently selected directory or file for the file picker.
55         GluiTextInput input;
56 
57         /// Label with suggestions to the text.
58         GluiRichLabel suggestions;
59 
60         /// Filename typed by the user, before choosing suggestions.
61         string typedFilename;
62 
63         /// Currently chosen suggestion. 0 is no suggestion chosen.
64         size_t currentSuggestion;
65 
66     }
67 
68     /// Create a file picker.
69     ///
70     /// Note: This is an "overlay" node, so it's expected to be placed in a global `onionFrame`. The constructor doesn't
71     /// accept a layout parameter, as there is a default, constant one, required for the node to work correctly. This
72     /// node is also hidden by default.
73     this(const Theme theme, string name, void delegate() @trusted submitted,
74         void delegate() @trusted cancelled = null)
75     do {
76 
77         super(
78             .layout(1, NodeAlign.center, NodeAlign.start),
79             theme,
80 
81             titleLabel  = label(name),
82             input       = textInput("Path to file...", submitted),
83             suggestions = richLabel(),
84         );
85 
86         this.cancelled = cancelled;
87 
88         // Hide the node
89         hide();
90 
91         // Windows is silly
92         version (Windows) input.value = `C:\`;
93         else input.value = expandTilde("~/");
94 
95         typedFilename = input.value;
96 
97         // Bind events
98         input.changed = () {
99 
100             // Trigger an event
101             if (changed) changed();
102 
103             // Update suggestions
104             typedFilename = input.value;
105             currentSuggestion = 0;
106             updateSuggestions();
107 
108         };
109 
110         input.submitted = () {
111 
112             // Suggestion checked
113             if (currentSuggestion != 0) {
114 
115                 // Activate it
116                 typedFilename = input.value;
117                 currentSuggestion = 0;
118                 updateSuggestions();
119 
120                 // Restore focus
121                 focus();
122 
123             }
124 
125             // Final submit
126             else {
127 
128                 // Submit the selection
129                 if (submitted) submitted();
130 
131                 // Remove focus
132                 super.isFocused = false;
133                 savedFocus = false;
134 
135                 // Automatically hide when submitted
136                 hide();
137 
138             }
139         };
140 
141     }
142 
143     this(string name, void delegate() @trusted submitted, void delegate() @trusted cancelled = null) {
144 
145         this(null, name, submitted, cancelled);
146 
147     }
148 
149     ref inout(string) text() inout {
150 
151         return titleLabel.text;
152 
153     }
154 
155     inout(string) value() inout {
156 
157         return input.value;
158 
159     }
160 
161     string value(string newValue) {
162 
163         return typedFilename = input.value = newValue;
164 
165     }
166 
167     /// Cancel picking files, triggering `cancelled` event.
168     void cancel() {
169 
170         // Call callback if it exists
171         if (cancelled) cancelled();
172 
173         // Hide
174         hide();
175 
176         savedFocus = false;
177         super.isFocused = false;
178 
179     }
180 
181     /// Refresh the suggestion list.
182     void updateSuggestions() {
183 
184         const values = valueTuple;
185         const dir  = values[0];
186         const file = values[1];
187 
188         suggestions.clear();
189 
190         // Make sure the directory exists
191         if (!dir.exists || !dir.isDir) return;
192 
193         // Check the entries
194         addSuggestions();
195 
196         // This suggestion was removed
197         if (currentSuggestion > suggestions.textParts.length) {
198 
199             currentSuggestion = suggestions.textParts.length;
200 
201         }
202 
203         updateSize();
204 
205     }
206 
207     private void addSuggestions() @trusted {
208 
209         const values = valueTuple;
210         const dir  = values[0];
211         const file = values[1];
212 
213         ulong num;
214         foreach (entry; dir.dirEntries(file ~ "*", SpanMode.shallow)) {
215 
216             const name = entry.name.baseName;
217 
218             // Ignore hidden directories if not prompted
219             if (!file.length && name.startsWith(".")) continue;
220 
221             // Stop after 10 entries.
222             if (num++ >= 10) break;
223 
224 
225             const prefix = num > 1 ? "\n" : "";
226 
227             // Get the style
228             auto style = currentSuggestion == num
229                 ? selectedStyle
230                 : null;
231 
232             // Found a directory
233             if (entry.isDir) suggestions.push(style, prefix ~ name ~ "/");
234 
235             // File
236             else suggestions.push(style, prefix ~ name);
237 
238         }
239 
240     }
241 
242     /// Get the value as a (directory, file) tuple.
243     private auto valueTuple() const {
244 
245         return valueTuple(input.value);
246 
247     }
248 
249     /// Ditto.
250     private auto valueTuple(string path) const {
251 
252         // Directory
253         if (path.endsWith(dirSeparator)) {
254 
255             return tuple(path, "");
256 
257         }
258 
259         const file = path.baseName;
260         return tuple(
261             path.chomp(file).to!string,
262             file,
263         );
264 
265     }
266 
267     /// Offset currently chosen selection by number.
268     private void offsetSuggestion(long n) {
269 
270         const suggestionCount = (suggestions.textParts.length + 1);
271 
272         auto previous = currentSuggestion;
273         currentSuggestion = (suggestionCount + currentSuggestion + n) % suggestionCount;
274 
275         // Clear style of the previous selection
276         if (previous != 0 && previous < suggestionCount) {
277 
278             suggestions.textParts[previous - 1].style = null;
279 
280         }
281 
282         // Style thew new item
283         if (currentSuggestion != 0) {
284 
285             auto part = &suggestions.textParts[currentSuggestion - 1];
286             part.style = selectedStyle;
287 
288             input.value = valueTuple(typedFilename)[0] ~ part.text.stripLeft;
289 
290         }
291 
292         // Nothing selected
293         else {
294 
295             // Restore original text
296             input.value = typedFilename;
297 
298         }
299 
300     }
301 
302     override void focus() {
303 
304         savedFocus = true;
305 
306         // Focus the input instead.
307         tree.focus = input;
308 
309     }
310 
311     override bool isFocused() const {
312 
313         return input.isFocused();
314 
315     }
316 
317     protected override void drawImpl(Rectangle outer, Rectangle inner) @trusted {
318 
319         // Wasn't focused
320         if (!savedFocus) {
321 
322             // Focus now
323             focus();
324 
325             // Refresh suggestions
326             updateSuggestions();
327 
328         }
329 
330         // Just lost focus
331         else if (!isFocused) {
332 
333             cancel();
334             return;
335 
336         }
337 
338 
339         with (KeyboardKey) {
340 
341             // If escape was pressed
342             if (IsKeyPressed(KEY_ESCAPE)) {
343 
344                 cancel();
345                 return;
346 
347             }
348 
349             // Ctrl
350             else if (IsKeyDown(KEY_LEFT_CONTROL)) {
351 
352                 // Vim
353                 if (IsKeyPressed(KEY_K)) offsetSuggestion(-1);
354                 else if (IsKeyPressed(KEY_J)) offsetSuggestion(1);
355 
356                 // Emacs
357                 if (IsKeyPressed(KEY_P)) offsetSuggestion(-1);
358                 else if (IsKeyPressed(KEY_N)) offsetSuggestion(1);
359 
360             }
361 
362             // Alt
363             else if (IsKeyDown(KEY_LEFT_ALT)) {
364 
365                 // Dir up
366                 if (IsKeyPressed(KEY_UP)) {
367 
368                     typedFilename = input.value = input.value.dirName;
369                     updateSuggestions();
370 
371                 }
372 
373             }
374 
375             // Go up
376             else if (IsKeyPressed(KEY_UP)) offsetSuggestion(-1);
377 
378             // Go down
379             else if (IsKeyPressed(KEY_DOWN)) offsetSuggestion(1);
380 
381         }
382 
383         super.drawImpl(outer, inner);
384 
385     }
386 
387     protected override void resizeImpl(Vector2 space) {
388 
389         // Larger windows
390         if (space.x > 600) {
391 
392             // Add margin
393             input.size.x = space.x / 10 + 540;
394 
395         }
396 
397         else input.size.x = space.x;
398 
399         // Resize the node itself
400         super.resizeImpl(space);
401 
402     }
403 
404     protected override void mouseImpl() {
405 
406         input.focus();
407 
408     }
409 
410     // Does nothing
411     protected override bool keyboardImpl() {
412 
413         assert(false, "FilePicker cannot directly have focus; call filePicker.focus to resolve automatically");
414 
415     }
416 
417 }