1 ///
2 module glui.text_input;
3 
4 import raylib;
5 
6 import glui.node;
7 import glui.input;
8 import glui.label;
9 import glui.style;
10 import glui.utils;
11 import glui.scroll;
12 import glui.structs;
13 
14 alias textInput = simpleConstructor!GluiTextInput;
15 
16 /// Raylib: Get pressed char
17 private extern (C) int GetCharPressed() nothrow @nogc;
18 
19 @safe:
20 
21 /// Text input field.
22 ///
23 /// Styles: $(UL
24 ///     $(LI `style` = Default style for the input.)
25 ///     $(LI `focusStyle` = Style for when the input is focused.)
26 ///     $(LI `emptyStyle` = Style for when the input is empty, i.e. the placeholder is visible. Text should usually be
27 ///         grayed out.)
28 /// )
29 class GluiTextInput : GluiInput!GluiNode {
30 
31     mixin DefineStyles!(
32         "emptyStyle", q{ style },
33     );
34     mixin ImplHoveredRect;
35 
36     /// Time in seconds before the cursor toggles 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         /// TODO. If true, this input accepts multiple lines.
51         bool multiline;
52 
53     }
54 
55     /// Underlying label controlling the content. Needed to properly adjust it to scroll.
56     private GluiScrollable!(TextImpl, "true") contentLabel;
57 
58     static foreach (index; 0 .. BasicNodeParamLength) {
59 
60         /// Create a text input.
61         /// Params:
62         ///     sup         = Node parameters.
63         ///     placeholder = Placeholder text for the field.
64         ///     submitted   = Callback for when the field is submitted (enter pressed, ctrl+enter if multiline).
65         this(BasicNodeParam!index sup, string placeholder = "", void delegate() @trusted submitted = null) {
66 
67             super(sup);
68             this.placeholder = placeholder;
69             this.submitted = submitted;
70 
71             // Create the label
72             this.contentLabel = new typeof(contentLabel)(.layout!(1, "fill"));
73 
74             with (this.contentLabel) {
75 
76                 scrollBar.width = 0;
77                 disableWrap = true;
78                 ignoreMouse = true;
79 
80             }
81 
82         }
83 
84     }
85 
86     protected override void resizeImpl(Vector2 area) {
87 
88         import std.algorithm : max;
89 
90         // Set the size
91         minSize = size;
92 
93         // Single line
94         if (!multiline) {
95 
96             // Set height to at least the font size
97             minSize.y = max(minSize.y, style.fontSize * style.lineHeight);
98 
99         }
100 
101         // Set the label text
102         contentLabel.text = (value == "") ? placeholder : value;
103 
104         // Inherit main style
105         const theme = [
106 
107             &TextImpl.styleKey: cast(const Style) style,
108 
109         ];
110 
111         // Resize the label
112         contentLabel.resize(tree, theme, Vector2(0, minSize.y));
113 
114     }
115 
116     protected override void drawImpl(Rectangle outer, Rectangle inner) @trusted {
117 
118         // Note: We're drawing the label in `outer` as the presence of the label is meant to be transparent.
119 
120         import std.algorithm : min, max;
121 
122         const style = pickStyle();
123         const scrollOffset = max(0, contentLabel.scrollMax - inner.w);
124 
125         // Fill the background
126         style.drawBackground(outer);
127 
128         // Copy the style to the label
129         contentLabel.activeStyle = style;
130 
131         // Set the scroll
132         contentLabel.scroll = cast(size_t) scrollOffset;
133 
134         // If the box isn't focused
135         if (!isFocused) {
136 
137             // Just draw the text
138             contentLabel.draw(outer);
139             return;
140 
141         }
142 
143         // Draw the label
144         contentLabel.draw(outer);
145 
146         // Add a blinking caret
147         if (GetTime % (blinkTime*2) < blinkTime) {
148 
149             const lineHeight = style.fontSize * style.lineHeight;
150             const margin = style.fontSize / 10f;
151 
152             // Put the caret at the start if the placeholder is shown
153             const textWidth = value.length
154                 ? min(contentLabel.scrollMax, inner.w)
155                 : 0;
156 
157             // Get caret position
158             const end = Vector2(
159                 inner.x + textWidth,
160                 inner.y + inner.height,
161             );
162 
163             // Draw the caret
164             DrawLineV(
165                 end - Vector2(0, lineHeight - margin),
166                 end - Vector2(0, margin),
167                 style.textColor
168             );
169 
170         }
171 
172     }
173 
174     // Do nothing, we take mouse focus while drawing.
175     protected override void mouseImpl() @trusted {
176 
177         // Update status
178         if (IsMouseButtonDown(MouseButton.MOUSE_LEFT_BUTTON)) {
179 
180             isFocused = true;
181 
182         }
183 
184     }
185 
186     protected override bool keyboardImpl() @trusted {
187 
188         import std.uni : isAlpha, isWhite;
189         import std.range : back;
190         import std.string : chop;
191 
192         bool backspace = false;
193         string input;
194 
195         // Get pressed key
196         while (true) {
197 
198             // Backspace
199             if (value.length && IsKeyPressed(KeyboardKey.KEY_BACKSPACE)) {
200 
201                 /// If true, delete whole words
202                 const word = IsKeyDown(KeyboardKey.KEY_LEFT_CONTROL);
203 
204                 // Remove the last character
205                 do {
206 
207                     const lastChar = value.back;
208                     value = value.chop;
209 
210                     backspace = true;
211 
212                     // Stop instantly if there are no characters left
213                     if (value.length == 0) break;
214 
215 
216                     // Whitespace, continue deleting
217                     if (lastChar.isWhite) continue;
218 
219                     // Matching alpha, continue deleting
220                     else if (value.back.isAlpha == lastChar.isAlpha) continue;
221 
222                     // Break in other cases
223                     break;
224 
225                 }
226 
227                 // Repeat only if requested to delete whole words
228                 while (word);
229 
230             }
231 
232             // Submit
233             if (!multiline && IsKeyPressed(KeyboardKey.KEY_ENTER)) {
234 
235                 isFocused = false;
236                 if (submitted) submitted();
237 
238                 return true;
239 
240             }
241 
242 
243             // Read text
244             if (const key = GetCharPressed()) {
245 
246                 // Append to char arrays
247                 input ~= cast(dchar) key;
248 
249             }
250 
251             // Stop if nothing left
252             else break;
253 
254         }
255 
256         value ~= input;
257 
258         // Trigger callback
259         if ((input.length || backspace) && changed) {
260 
261             // Trigger change
262             changed();
263 
264             // Update the size of the input
265             updateSize();
266 
267             return true;
268 
269         }
270 
271         // Even if nothing changed, user might have held the key for a while which this function probably wouldn't have
272         // caught, so we'd be returning false-positives all the time.
273         // The safest way is to just return true always, text input really is complex enough we can assume we did take
274         // any input there could be.
275         return true;
276 
277     }
278 
279     override const(Style) pickStyle() const {
280 
281         // Disabled
282         if (isDisabledInherited) return disabledStyle;
283 
284         // Focused
285         else if (isFocused) return focusStyle;
286 
287         // Empty text (display placeholder)
288         else if (value == "") return emptyStyle;
289 
290         // Other styles
291         else return super.pickStyle();
292 
293     }
294 
295 }
296 
297 private class TextImpl : GluiLabel {
298 
299     mixin DefineStyles!(
300         "activeStyle", q{ style }
301     );
302 
303     this(T...)(T args) {
304 
305         super(args);
306 
307     }
308 
309     // Same as parent, but doesn't draw background
310     override void drawImpl(Rectangle outer, Rectangle inner) {
311 
312         const style = pickStyle();
313         style.drawText(inner, text);
314 
315     }
316 
317     override const(Style) pickStyle() const {
318 
319         return activeStyle;
320 
321     }
322 
323 }