1 /// 2 module fluid.text_input; 3 4 import std.string; 5 6 import fluid.node; 7 import fluid.text; 8 import fluid.input; 9 import fluid.label; 10 import fluid.style; 11 import fluid.utils; 12 import fluid.scroll; 13 import fluid.backend; 14 import fluid.structs; 15 16 alias textInput = simpleConstructor!TextInput; 17 18 @safe: 19 20 /// Text input field. 21 /// 22 /// Styles: $(UL 23 /// $(LI `style` = Default style for the input.) 24 /// $(LI `focusStyle` = Style for when the input is focused.) 25 /// $(LI `emptyStyle` = Style for when the input is empty, i.e. the placeholder is visible. Text should usually be 26 /// grayed out.) 27 /// ) 28 class TextInput : InputNode!Node { 29 30 mixin defineStyles!( 31 "emptyStyle", q{ style }, 32 ); 33 mixin implHoveredRect; 34 mixin enableInputActions; 35 36 /// Time in seconds between changes in cursor visibility. 37 static immutable float blinkTime = 1; 38 39 public { 40 41 /// Size of the field. 42 auto size = Vector2(200, 0); 43 44 /// Value of the field. 45 string value; 46 47 /// A placeholder text for the field, displayed when the field is empty. Style using `emptyStyle`. 48 string placeholder; 49 50 deprecated("multiline was never supported and will be deleted in 0.7.0") { 51 52 bool multiline() const { return false; } 53 bool multiline(bool) { return false; } 54 55 } 56 57 } 58 59 private { 60 61 /// Underlying label controlling the content. Needed to properly adjust it to scroll. 62 Scrollable!(TextImpl, "true") contentLabel; 63 64 } 65 66 deprecated("Use this(NodeParams, string, void delegate() @safe submitted) instead") { 67 68 static foreach (index; 0 .. BasicNodeParamLength) { 69 70 /// Create a text input. 71 /// Params: 72 /// sup = Node parameters. 73 /// placeholder = Placeholder text for the field. 74 /// submitted = Callback for when the field is submitted. 75 this(BasicNodeParam!index sup, string placeholder = "", void delegate() @trusted submitted = null) { 76 77 super(NodeParams(sup)); 78 this.placeholder = placeholder; 79 this.submitted = submitted; 80 81 // Create the label 82 this.contentLabel = new typeof(contentLabel)(NodeParams(.layout!(1, "fill")), ""); 83 84 with (this.contentLabel) { 85 86 // Make the scrollbar invisible 87 scrollBar.disable(); 88 scrollBar.width = 0; 89 // Note: We're not hiding the scrollbar, so it may adjust used values to the size of the input 90 91 disableWrap(); 92 ignoreMouse = true; 93 94 } 95 96 } 97 98 } 99 100 } 101 102 /// Create a text input. 103 /// Params: 104 /// params = Node parameters. 105 /// placeholder = Placeholder text for the field. 106 /// submitted = Callback for when the field is submitted. 107 this(NodeParams params, string placeholder = "", void delegate() @trusted submitted = null) { 108 109 super(params); 110 this.placeholder = placeholder; 111 this.submitted = submitted; 112 113 // Create the label 114 this.contentLabel = new typeof(contentLabel)(NodeParams(.layout!(1, "fill")), ""); 115 116 with (this.contentLabel) { 117 118 // Make the scrollbar invisible 119 scrollBar.disable(); 120 scrollBar.width = 0; 121 // Note: We're not hiding the scrollbar, so it may adjust used values to the size of the input 122 123 disableWrap(); 124 ignoreMouse = true; 125 126 } 127 128 } 129 130 protected override void resizeImpl(Vector2 area) { 131 132 import std.algorithm : max; 133 134 // Set the size 135 minSize = size; 136 137 // Set height to at least the font size 138 minSize.y = max(minSize.y, style.font.lineHeight); 139 140 // Set the label text 141 contentLabel.text = (value == "") ? placeholder : value; 142 143 // Inherit main style 144 // TODO reuse the hashmap maybe? 145 auto childTheme = theme.makeTheme!q{ 146 147 Label.styleAdd!q{ 148 149 // Those are already included in our theme, we should remove them 150 margin = 0; 151 padding = 0; 152 border = 0; 153 154 }; 155 156 }; 157 158 // Resize the label 159 contentLabel.resize(tree, childTheme, Vector2(0, minSize.y)); 160 161 } 162 163 protected override void drawImpl(Rectangle outer, Rectangle inner) @trusted { 164 165 // Note: We're drawing the label in `outer` as the presence of the label is meant to be transparent. 166 167 import std.datetime : Clock; 168 import std.algorithm : min, max; 169 170 auto style = pickStyle(); 171 172 const scrollOffset = max(0, contentLabel.scrollMax - inner.w); 173 174 // Fill the background 175 style.drawBackground(tree.io, outer); 176 177 // Copy the style to the label 178 contentLabel.activeStyle = style; 179 180 // Set the scroll 181 contentLabel.scroll = cast(size_t) scrollOffset; 182 183 // Draw the text 184 contentLabel.draw(inner); 185 186 // Ignore the rest if the node isn't focused 187 if (!isFocused || isDisabledInherited) return; 188 189 auto timeSecs = Clock.currTime.second; 190 191 // Add a blinking caret 192 if (timeSecs % (blinkTime*2) < blinkTime) { 193 194 const lineHeight = style.typeface.lineHeight; 195 const margin = style.typeface.lineHeight / 10f; 196 197 // Put the caret at the start if the placeholder is shown 198 const textWidth = value.length 199 ? min(contentLabel.scrollMax, inner.w) 200 : 0; 201 202 // Get caret position 203 const end = Vector2( 204 inner.x + textWidth, 205 inner.y + inner.height, 206 ); 207 208 // Draw the caret 209 io.drawLine( 210 end - Vector2(0, lineHeight - margin), 211 end - Vector2(0, margin), 212 focusStyle.textColor 213 ); 214 215 } 216 217 } 218 219 protected override bool keyboardImpl() @trusted { 220 221 import std.uni : isAlpha, isWhite; 222 import std.range : back; 223 import std.string : chop; 224 225 string input; 226 227 // Get pressed key 228 while (true) { 229 230 // Read text 231 if (const key = io.inputCharacter) { 232 233 // Append to char arrays 234 input ~= cast(dchar) key; 235 236 } 237 238 // Stop if there's nothing left 239 else break; 240 241 } 242 243 // Typed something 244 if (input.length) { 245 246 // Update the value 247 value ~= input; 248 249 // Trigger the callback 250 if (changed) changed(); 251 252 // Update the size of the input 253 updateSize(); 254 255 return true; 256 257 } 258 259 return true; 260 261 } 262 263 unittest { 264 265 auto io = new HeadlessBackend; 266 auto root = textInput("placeholder"); 267 268 root.io = io; 269 270 // Empty text 271 { 272 root.draw(); 273 274 assert(root.value == ""); 275 assert(root.contentLabel.text == "placeholder"); 276 assert(root.contentLabel.activeStyle is root.emptyStyle); 277 } 278 279 // Focus the box and input stuff 280 { 281 io.nextFrame; 282 io.inputCharacter("¡Hola, mundo!"); 283 root.focus(); 284 root.draw(); 285 286 assert(root.value == "¡Hola, mundo!"); 287 } 288 289 // Input stuff 290 { 291 io.nextFrame; 292 root.draw(); 293 294 assert(root.contentLabel.text == "¡Hola, mundo!"); 295 assert(root.contentLabel.activeStyle is root.focusStyle); 296 } 297 298 } 299 300 /// Submit the input. 301 @(FluidInputAction.submit) 302 protected void _submit() { 303 304 // Clear focus 305 isFocused = false; 306 307 // Run the callback 308 if (submitted) submitted(); 309 310 } 311 312 unittest { 313 314 int submitted; 315 316 auto io = new HeadlessBackend; 317 TextInput root; 318 319 root = textInput("placeholder", delegate { 320 submitted++; 321 assert(root.value == "Hello World"); 322 }); 323 324 root.io = io; 325 326 // Type stuff 327 { 328 root.value = "Hello World"; 329 root.focus(); 330 root.updateSize(); 331 root.draw(); 332 333 assert(submitted == 0); 334 assert(root.value == "Hello World"); 335 assert(root.contentLabel.text == "Hello World"); 336 } 337 338 // Submit 339 { 340 io.nextFrame; 341 io.press(KeyboardKey.enter); 342 root.draw(); 343 344 assert(submitted == 1); 345 } 346 347 } 348 349 /// Erase last inputted word. 350 @(FluidInputAction.backspaceWord) 351 void chopWord() { 352 353 import std.uni; 354 import std.range; 355 356 // Run while there's something to process 357 while (value != "") { 358 359 // Remove the last character 360 const lastChar = value.back; 361 value = value.chop; 362 363 // Stop if empty 364 if (value == "") break; 365 366 { 367 368 // Continue if last removed character was whitespace 369 if (lastChar.isWhite) continue; 370 371 // Continue deleting if two last characters were alphanumeric, or neither of them was 372 if (value.back.isAlphaNum == lastChar.isAlphaNum) continue; 373 374 } 375 376 // Break in other cases 377 break; 378 379 } 380 381 // Trigger the callback 382 if (changed) changed(); 383 384 // Update the size of the box 385 updateSize(); 386 387 } 388 389 unittest { 390 391 auto io = new HeadlessBackend; 392 auto root = textInput(); 393 394 root.io = io; 395 396 // Type stuff 397 { 398 root.value = "Hello World"; 399 root.focus(); 400 root.updateSize(); 401 root.draw(); 402 403 assert(root.value == "Hello World"); 404 assert(root.contentLabel.text == "Hello World"); 405 } 406 407 // Erase a word 408 { 409 io.nextFrame; 410 root.chopWord; 411 root.draw(); 412 413 assert(root.value == "Hello "); 414 assert(root.contentLabel.text == "Hello "); 415 assert(root.contentLabel.activeStyle is root.focusStyle); 416 } 417 418 // Erase a word 419 { 420 io.nextFrame; 421 root.chopWord; 422 root.draw(); 423 424 assert(root.value == ""); 425 assert(root.contentLabel.text == ""); 426 assert(root.contentLabel.activeStyle is root.emptyStyle); 427 } 428 429 // Typing should be disabled while erasing 430 { 431 io.press(KeyboardKey.leftControl); 432 io.press(KeyboardKey.w); 433 io.inputCharacter('w'); 434 435 root.draw(); 436 437 assert(root.value == ""); 438 assert(root.contentLabel.text == ""); 439 assert(root.contentLabel.activeStyle is root.emptyStyle); 440 } 441 442 } 443 444 /// Erase last inputted letter. 445 @(FluidInputAction.backspace) 446 void chop() { 447 448 // Ignore if the box is empty 449 if (value == "") return; 450 451 // Remove the last character 452 value = value.chop; 453 454 // Trigger the callback 455 if (changed) changed(); 456 457 // Update the size of the box 458 updateSize(); 459 460 } 461 462 unittest { 463 464 auto io = new HeadlessBackend; 465 auto root = textInput(); 466 467 root.io = io; 468 469 // Type stuff 470 { 471 root.value = "hello‽"; 472 root.focus(); 473 root.updateSize(); 474 root.draw(); 475 476 assert(root.value == "hello‽"); 477 assert(root.contentLabel.text == "hello‽"); 478 } 479 480 // Erase a letter 481 { 482 io.nextFrame; 483 root.chop; 484 root.draw(); 485 486 assert(root.value == "hello"); 487 assert(root.contentLabel.text == "hello"); 488 assert(root.contentLabel.activeStyle is root.focusStyle); 489 } 490 491 // Erase a letter 492 { 493 io.nextFrame; 494 root.chop; 495 root.draw(); 496 497 assert(root.value == "hell"); 498 assert(root.contentLabel.text == "hell"); 499 assert(root.contentLabel.activeStyle is root.focusStyle); 500 } 501 502 // Typing should be disabled while erasing 503 { 504 io.press(KeyboardKey.backspace); 505 io.inputCharacter("o, world"); 506 507 root.draw(); 508 509 assert(root.value == "hel"); 510 assert(root.contentLabel.activeStyle is root.focusStyle); 511 } 512 513 } 514 515 override inout(Style) pickStyle() inout { 516 517 // Disabled 518 if (isDisabledInherited) return disabledStyle; 519 520 // Empty text (display placeholder) 521 else if (value == "") return emptyStyle; 522 523 // Focused 524 else if (isFocused) return focusStyle; 525 526 // Other styles 527 else return super.pickStyle(); 528 529 } 530 531 } 532 533 private class TextImpl : Label { 534 535 mixin DefineStyles!( 536 "activeStyle", q{ style } 537 ); 538 539 this(T...)(T args) { 540 541 super(args); 542 543 } 544 545 // Same as parent, but doesn't draw background 546 override void drawImpl(Rectangle outer, Rectangle inner) { 547 548 const style = pickStyle(); 549 text.draw(style, inner); 550 551 } 552 553 override inout(Style) pickStyle() inout { 554 555 return activeStyle; 556 557 } 558 559 }