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 }