1 module rpui.widgets.panel.widget;
2 
3 import std.container;
4 import std.algorithm.comparison;
5 import std.stdio;
6 
7 import rpdl;
8 
9 import rpui.primitives;
10 import rpui.widget;
11 import rpui.scroll;
12 import rpui.input;
13 import rpui.view;
14 import rpui.cursor;
15 import rpui.events;
16 import rpui.widget_events;
17 import rpui.math;
18 import rpui.render.renderer;
19 import rpui.render.components : State;
20 
21 import rpui.widgets.panel.renderer;
22 import rpui.widgets.panel.scroll_button;
23 import rpui.widgets.panel.theme_loader;
24 
25 /**
26  * Panel widget is the container for other widgets with scrolling,
27  * resizing, allow change placement by drag and drop.
28  */
29 class Panel : Widget, FocusScrollNavigation {
30     enum Background {
31         transparent,  /// Render without color.
32         light,
33         dark,
34         action  /// Color for actions like OK, Cancel etc.
35     }
36 
37     @field float minSize = 40;  /// Minimum size of panel.
38     @field float maxSize = 999;  /// Maximum size of panel.
39     @field Background background = Background.light;  /// Background color of panel.
40     @field bool userCanResize = true;
41     @field bool userCanHide = false;
42     @field bool userCanDrag = false;
43 
44     /// If true, then panel is open and will be rendered all content else only header.
45     @field bool isOpen = true;
46     @field bool darkSplit = false;  /// If true, then panel split will be dark.
47     @field bool showSplit = true;  /// If true, render panel split else no.
48 
49     @field bool showVerticalScrollButton = true;
50     @field bool showHorizontalScrollButton = true;
51 
52     @field utf32string caption = "";
53     @field RegionAlign splitAlign = RegionAlign.client;
54 
55     package @property inout(RegionAlign) splitRegionAlign() inout {
56         return regionAlign == RegionAlign.client || regionAlign == RegionAlign.none
57             ? oppositeRegionAlign(splitAlign)
58             : regionAlign;
59     }
60 
61     struct Measure {
62         float headerHeight;
63         float scrollButtonMinSize;
64         float horizontalScrollRegionWidth;
65         float verticalScrollRegionWidth;
66         float splitThickness;
67     }
68 
69     struct Split {
70         bool isClick;
71         bool isEnter;
72         float cursorRangeSize = 8;
73         Rect cursorRangeRect;
74         float thickness;
75     }
76 
77     struct Header {
78         float height = 0;
79         bool isEnter = false;
80     }
81 
82     package Measure measure;
83     package PanelThemeLoader themeLoader;
84 
85     private vec2 lastSize = 0;
86 
87     package Split split;
88     package Header header;
89     package ScrollButton horizontalScrollButton = ScrollButton(Orientation.horizontal);
90     package ScrollButton verticalScrollButton = ScrollButton(Orientation.vertical);
91 
92     package @property inout(State) headerState() inout {
93         if (isEnter) {
94             return State.enter;
95         } else {
96             return State.leave;
97         }
98     }
99 
100     this(in string style = "Panel") {
101         super(style);
102         skipFocus = true;
103         renderer = new PanelRenderer();
104     }
105 
106     override void renderChildren() {
107         if (!drawChildren)
108             return;
109 
110         const scissor = getScissor();
111         view.pushScissor(scissor);
112 
113         foreach (Widget child; children) {
114             if (!child.isVisible)
115                 continue;
116 
117             if (!pointInRect(view.mousePos, scissor)) {
118                 child.isEnter = false;
119                 child.isClick = false;
120             }
121 
122             child.onRender();
123         }
124 
125         view.popScissor();
126     }
127 
128     protected override void onCreate() {
129         super.onCreate();
130 
131         measure = themeLoader.createMeasureFromRpdl(view.theme.tree.data, style);
132 
133         header.height = measure.headerHeight;
134         split.thickness = measure.splitThickness;
135 
136         horizontalScrollButton.attach(this);
137         verticalScrollButton.attach(this);
138     }
139 
140     override void onRender() {
141         renderer.onRender();
142     }
143 
144     override void onProgress(in ProgressEvent event) {
145         super.onProgress(event);
146 
147         split.isEnter = false;
148 
149         handleResize();
150         headerOnProgress();
151 
152         // Update render elements position and sizes
153         locator.updateRegionAlign();
154         locator.updateAbsolutePosition();
155 
156         updateInnerOffset();
157         updateSize();
158 
159         with (horizontalScrollButton)
160             contentOffset.x = visible ? scrollController.contentOffset : 0;
161 
162         with (verticalScrollButton)
163             contentOffset.y = visible ? scrollController.contentOffset : 0;
164 
165         if (!isFreezingSource() && !isFrozen()) {
166             horizontalScrollButton.onProgress();
167             verticalScrollButton.onProgress();
168         } else {
169             horizontalScrollButton.isEnter = false;
170             verticalScrollButton.isEnter = false;
171         }
172     }
173 
174     void headerOnProgress() {
175         if (!userCanHide)
176             return;
177 
178         const vec2 headerSize = vec2(size.x, header.height);
179         const Rect rect = Rect(absolutePosition, headerSize);
180         header.isEnter = pointInRect(view.mousePos, rect);
181     }
182 
183     override void updateSize() {
184         if (isOpen) {
185             updatePanelSize();
186 
187             horizontalScrollButton.updateSize();
188             verticalScrollButton.updateSize();
189         }
190 
191         with (horizontalScrollButton)
192             contentOffset.x = visible ? scrollController.contentOffset : 0;
193 
194         with (verticalScrollButton)
195             contentOffset.y = visible ? scrollController.contentOffset : 0;
196     }
197 
198     private void updatePanelSize() {
199         if (heightType == SizeType.wrapContent) {
200             size.y = innerBoundarySize.y;
201         }
202 
203         if (widthType == SizeType.wrapContent) {
204             size.x = innerBoundarySize.x;
205         }
206     }
207 
208     /// Add extra inner offset depends of which elements are visible.
209     protected void updateInnerOffset() {
210         extraInnerOffset.left = 0;
211 
212         if (verticalScrollButton.visible) {
213             extraInnerOffset.right = verticalScrollButton.width;
214         } else {
215             extraInnerOffset.right = 0;
216         }
217 
218         if (horizontalScrollButton.visible) {
219             extraInnerOffset.bottom = horizontalScrollButton.width;
220         } else {
221             extraInnerOffset.bottom = 0;
222         }
223 
224         if (userCanHide) {
225             extraInnerOffset.top = header.height;
226         } else {
227             extraInnerOffset.top = 0;
228         }
229 
230         // Split extra inner offset
231         if (userCanResize || showSplit) {
232             const thickness = 1;
233 
234             switch (regionAlign) {
235                 case RegionAlign.top:
236                     extraInnerOffset.bottom += thickness;
237                     break;
238 
239                 case RegionAlign.bottom:
240                     extraInnerOffset.top += thickness;
241                     break;
242 
243                 case RegionAlign.right:
244                     extraInnerOffset.left += thickness;
245                     break;
246 
247                 case RegionAlign.left:
248                     extraInnerOffset.right += thickness;
249                     break;
250 
251                 default:
252                     break;
253             }
254         }
255     }
256 
257     // Resize panel when split is clicked.
258     void handleResize() {
259         if (!split.isClick)
260             return;
261 
262         switch (regionAlign) {
263             case RegionAlign.top:
264                 size.y = lastSize.y + view.mousePos.y - view.mouseClickPos.y;
265                 break;
266 
267             case RegionAlign.bottom:
268                 size.y = lastSize.y - view.mousePos.y + view.mouseClickPos.y;
269                 break;
270 
271             case RegionAlign.left:
272                 size.x = lastSize.x + view.mousePos.x - view.mouseClickPos.x;
273                 break;
274 
275             case RegionAlign.right:
276                 size.x = lastSize.x - view.mousePos.x + view.mouseClickPos.x;
277                 break;
278 
279             default:
280                 break;
281         }
282 
283         if (regionAlign == RegionAlign.top || regionAlign == RegionAlign.bottom)
284             size.y = clamp(size.y, minSize, maxSize);
285 
286         if (regionAlign == RegionAlign.left || regionAlign == RegionAlign.right)
287             size.x = clamp(size.x, minSize, maxSize);
288 
289         parent.events.notify(ResizeEvent());
290         view.rootWidget.updateAll();
291     }
292 
293     private Rect getScissor() {
294         Rect scissor;
295         const thickness = split.thickness;
296 
297         scissor.point = absolutePosition + extraInnerOffsetStart;
298         scissor.size = size;
299 
300         if (userCanHide) {
301             scissor.size = scissor.size - vec2(0, header.height);
302         }
303 
304         if (userCanResize || showSplit) {
305             if (regionAlign == RegionAlign.top || regionAlign == RegionAlign.bottom) {
306                 // Horizontal orientation
307                 scissor.size = scissor.size - vec2(0, thickness);
308             }
309             else if (regionAlign == RegionAlign.left || regionAlign == RegionAlign.right) {
310                 // Vertical orientation
311                 scissor.size = scissor.size - vec2(thickness, 0);
312             }
313         }
314 
315         return scissor;
316     }
317 
318     /// Change system cursor when mouse entering split.
319     override void onCursor() {
320         if (!userCanResize || !isOpen || scrollButtonIsClicked)
321             return;
322 
323         if (regionAlign == RegionAlign.top || regionAlign == RegionAlign.bottom) {
324             const Rect rect = Rect(
325                 split.cursorRangeRect.left,
326                 split.cursorRangeRect.top - split.cursorRangeSize / 2.0f,
327                 split.cursorRangeRect.width,
328                 split.cursorRangeSize
329             );
330 
331             if (pointInRect(view.mousePos, rect) || split.isClick) {
332                 view.cursor = CursorIcon.vDoubleArrow;
333                 split.isEnter = true;
334                 horizontalScrollButton.isEnter = false;
335             }
336         }
337         else if (regionAlign == RegionAlign.left || regionAlign == RegionAlign.right) {
338             const Rect rect = Rect(
339                 split.cursorRangeRect.left - split.cursorRangeSize / 2.0f,
340                 split.cursorRangeRect.top,
341                 split.cursorRangeSize,
342                 split.cursorRangeRect.height
343             );
344 
345             if (pointInRect(view.mousePos, rect) || split.isClick) {
346                 view.cursor = CursorIcon.hDoubleArrow;
347                 split.isEnter = true;
348                 verticalScrollButton.isEnter = false;
349             }
350         }
351     }
352 
353     private bool scrollButtonIsClicked() {
354         return verticalScrollButton.isClick || horizontalScrollButton.isClick;
355     }
356 
357     override void scrollToWidget(Widget widget) {
358         const vec2 relativePosition = widget.absolutePosition -
359             (absolutePosition + extraInnerOffsetStart);
360 
361         with (verticalScrollButton.scrollController)
362             setOffsetInPx(relativePosition.y + contentOffset);
363 
364         with (horizontalScrollButton.scrollController)
365             setOffsetInPx(relativePosition.x + contentOffset);
366     }
367 
368     override void borderScrollToWidget(Widget widget) {
369         const vec2 relativePosition = widget.absolutePosition -
370             (absolutePosition + extraInnerOffsetStart);
371 
372         with (verticalScrollButton.scrollController) {
373             const float innerVisibleSize = visibleSize - extraInnerOffsetSize.y;
374             const float widgetScrollOffset = relativePosition.y + contentOffset;
375 
376             if (relativePosition.y < 0) {
377                 setOffsetInPx(widgetScrollOffset);
378             } else if (relativePosition.y + widget.size.y > innerVisibleSize) {
379                 setOffsetInPx(widgetScrollOffset - innerVisibleSize + widget.size.y);
380             }
381         }
382 
383         with (horizontalScrollButton.scrollController) {
384             const float innerVisibleSize = visibleSize - extraInnerOffsetSize.x;
385             const float widgetScrollOffset = relativePosition.x + contentOffset;
386 
387             if (relativePosition.x < 0) {
388                 setOffsetInPx(widgetScrollOffset);
389             } else if (relativePosition.x + widget.size.x > innerVisibleSize) {
390                 setOffsetInPx(widgetScrollOffset - innerVisibleSize + widget.size.x);
391             }
392         }
393     }
394 
395     /// Set scroll value in px.
396     void scrollToPx(in float x, in float y) {
397         verticalScrollButton.scrollController.setOffsetInPx(x);
398         horizontalScrollButton.scrollController.setOffsetInPx(y);
399     }
400 
401     /// Add value to scroll in px.
402     void scrollByPx(in float dx, in float dy) {
403         verticalScrollButton.scrollController.addOffsetInPx(dx);
404         horizontalScrollButton.scrollController.addOffsetInPx(dy);
405     }
406 
407     /// Set scroll value in percent.
408     void scrollToPercent(in float x, in float y) {
409         verticalScrollButton.scrollController.setOffsetInPercent(x);
410         horizontalScrollButton.scrollController.setOffsetInPercent(y);
411     }
412 
413     final void open() {
414         if (isOpen)
415             return;
416 
417         size = lastSize;
418         isOpen = true;
419         view.rootWidget.updateAll();
420     }
421 
422     final void close() {
423         if (!isOpen)
424             return;
425 
426         lastSize = size;
427         size.y = header.height;
428         isOpen = false;
429         view.rootWidget.updateAll();
430 
431         horizontalScrollButton.scrollController.setOffsetInPercent(0);
432         verticalScrollButton.scrollController.setOffsetInPercent(0);
433     }
434 
435     /// Toggle visibility of panel. If `isOpen` then method will close panel else open.
436     final void toggle() {
437         if (isOpen) {
438             close();
439         } else {
440             open();
441         }
442     }
443 
444 // Events ------------------------------------------------------------------------------------------
445 
446     override void onMouseUp(in MouseUpEvent event) {
447         verticalScrollButton.isClick = false;
448         horizontalScrollButton.isClick = false;
449 
450         if (split.isClick) {
451             split.isClick = false;
452             unfreezeUI();
453         }
454 
455         super.onMouseUp(event);
456     }
457 
458     /// Handle mouse down event - avoid it if UI is forzen.
459     override void onMouseDown(in MouseDownEvent event) {
460         if (isFreezingSource() && view.isNestedFreeze)
461             return;
462 
463         if (split.isEnter && isOpen && view.cursor != CursorIcon.inherit) {
464             lastSize = size;
465             split.isClick = true;
466             freezeUI();
467         }
468 
469         if (!isFreezingSource()) {
470             verticalScrollButton.isClick = verticalScrollButton.isEnter;
471             horizontalScrollButton.isClick = horizontalScrollButton.isEnter;
472 
473             verticalScrollButton.scrollController.onMouseDown(event);
474             horizontalScrollButton.scrollController.onMouseDown(event);
475         }
476 
477         onHeaderMouseDown();
478         super.onMouseDown(event);
479     }
480 
481     private void onHeaderMouseDown() {
482         if (!header.isEnter || !userCanHide)
483             return;
484 
485         toggle();
486     }
487 
488     override void onResize() {
489         horizontalScrollButton.scrollController.onResize();
490         verticalScrollButton.scrollController.onResize();
491 
492         super.onResize();
493     }
494 
495     protected override void onMouseWheelHandle(in int dx, in int dy) {
496         if (isFreezingSource() && view.isNestedFreeze)
497             return;
498 
499         Scrollable scrollable = cast(Scrollable) parent;
500 
501         int horizontalDelta = dx;
502         int verticalDelta = dy;
503 
504         if (!verticalScrollButton.scrollController.addOffsetInPx(-verticalDelta*20)) {
505             if (scrollable && parent.isOver && !parent.isFrozen()) {
506                 scrollable.onMouseWheelHandle(0, verticalDelta);
507             }
508         }
509 
510         if (!horizontalScrollButton.scrollController.addOffsetInPx(-horizontalDelta*20)) {
511             if (scrollable && parent.isOver && !parent.isFrozen()) {
512                 scrollable.onMouseWheelHandle(horizontalDelta, 0);
513             }
514         }
515     }
516 }