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 }