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 }