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 }