1 ///
2 module nanogui.experimental.list;
3 
4 /*
5 	NanoGUI was developed by Wenzel Jakob <wenzel.jakob@epfl.ch>.
6 	The widget drawing code is based on the NanoVG demo application
7 	by Mikko Mononen.
8 
9 	All rights reserved. Use of this source code is governed by a
10 	BSD-style license that can be found in the LICENSE.txt file.
11 */
12 
13 import std.algorithm : min, max;
14 import std.range : isRandomAccessRange, ElementType;
15 import nanogui.widget;
16 import nanogui.common : MouseButton, Vector2f, Vector2i, NanoContext;
17 import nanogui.experimental.utils : Model, isProcessible, TreePathVisitorImpl;
18 
19 /**
20  * Tree view widget.
21  */
22 class List(D) : Widget
23 	if (isProcessible!D)
24 {
25 public:
26 
27 	alias Data = D;
28 
29 	enum modelHasCollapsed = is(typeof(Model!Data.collapsed) == bool);
30 
31 	/**
32 	 * Adds a TreeView to the specified `parent`.
33 	 *
34 	 * Params:
35 	 *     parent = The Widget to add this TreeView to.
36 	 *     data   = The content of the widget.
37 	 */
38 	this(Widget parent, Data data)
39 	{
40 		super(parent);
41 		_data = data;
42 		_model = makeModel(_data);
43 		mScroll = 0.0f;
44 		this.data = data;
45 	}
46 
47 	// the getter for the data is private because
48 	// the widget does not own the data and
49 	// the widget can not be considered as
50 	// a data source
51 	private @property ref auto data() const
52 	{
53 		return _data;
54 	}
55 
56 	@property
57 	auto data(Data data)
58 	{
59 		_data = data;
60 		_model.update(data);
61 		_model.size = 0;
62 		_model_changed = true;
63 		calculateScrollableState;
64 		rm.loc.y.position = 0;
65 		visit(_model, _data, rm, 1);
66 	}
67 
68 	/// Callback that called on mouse clicking
69 	void delegate(MouseButton, ref const(TreePath)) onMousePressed;
70 
71 	void applyByTreePath(T)(ref const(TreePath) tree_path, void delegate(ref const(T) value) dg)
72 	{
73 		import nanogui.experimental.utils : applyByTreePath;
74 		applyByTreePath(_data, _model, tree_path.value[], dg);
75 	}
76 
77 	/// Return the current scroll amount as a value between 0 and 1. 0 means scrolled to the top and 1 to the bottom.
78 	float scroll() const { return mScroll; }
79 	/// Set the scroll amount to a value between 0 and 1. 0 means scrolled to the top and 1 to the bottom.
80 	void setScroll(float scroll) { mScroll = scroll; }
81 
82 	override void performLayout(NanoContext ctx)
83 	{
84 		super.performLayout(ctx);
85 
86 		mSize.y = parent.size.y - 2*parent.layout.margin;
87 		import nanogui.window : Window;
88 		if (auto window = cast(const Window)(parent) && window.title.length)
89 			mSize.y -= parent.theme.mWindowHeaderHeight;
90 		if (mSize.y < 0)
91 			mSize.y = 0;
92 
93 		calculateScrollableState();
94 	}
95 
96 	private void calculateScrollableState()
97 	{
98 		if (_model_changed)
99 		{
100 			const scroll_position = mScroll * (_model.size - size.y);
101 			import nanogui.experimental.utils : MeasuringVisitor;
102 			auto mv = MeasuringVisitor([size.x, fontSize]);
103 			_model.visitForward(_data, mv);
104 			mScroll = scroll_position / (_model.size - size.y);
105 			_model_changed = false;
106 		}
107 
108 		if (_model.size <= mSize.y)
109 			mScroll = 0;
110 	}
111 
112 	private bool isMouseInside(Vector2i p)
113 	{
114 		import nanogui.experimental.utils : isPointInRect;
115 		const rect_size = Vector2i(mSize.x, mSize.y);
116 		return isPointInRect(mPos, rect_size, p);
117 	}
118 
119 	override bool mouseDragEvent(Vector2i p, Vector2i rel, MouseButton button, int modifiers)
120 	{
121 		if (!isMouseInside(p))
122 			return false;
123 
124 		if (_pushed_scroll_btn)
125 		{
126 			// scroll button height
127 			float scrollh = height * min(1.0f, height / _model.size);
128 
129 			mScroll = max(0.0f, min(1.0f, mScroll + rel.y / (mSize.y - 8.0f - scrollh)));
130 			return true;
131 		}
132 
133 		return super.mouseDragEvent(p, rel, button, modifiers);
134 	}
135 
136 	override bool scrollEvent(Vector2i p, Vector2f rel)
137 	{
138 		if (_model.size > mSize.y)
139 		{
140 			mScroll = max(0.0f, min(1.0f, mScroll - 10*rel.y/_model.size));
141 			return true;
142 		}
143 
144 		return super.scrollEvent(p, rel);
145 	}
146 
147 	static if (modelHasCollapsed)
148 	{
149 		import std.typecons : Nullable;
150 
151 		Nullable!bool collapsed(int[] path)
152 		{
153 			import nanogui.experimental.utils : getPropertyByTreePath;
154 
155 			return getPropertyByTreePath!("collapsed", bool)(_data, _model, path);
156 		}
157 
158 		bool collapsed()
159 		{
160 			import std.exception : enforce;
161 			import nanogui.experimental.utils : getPropertyByTreePath;
162 
163 			auto v = getPropertyByTreePath!("collapsed", bool)(_data, _model, (int[]).init);
164 			enforce(!v.isNull);
165 			return v.get;
166 		}
167 
168 		void collapsed(bool value)
169 		{
170 			collapsed(null, value);
171 		}
172 
173 		void collapsed(int[] path, bool value)
174 		{
175 			import nanogui.experimental.utils : setPropertyByTreePath;
176 
177 			setPropertyByTreePath!"collapsed"(_data, _model, path, value);
178 			_model_changed = true;
179 			calculateScrollableState;
180 			screen.needToPerfomLayout = true;
181 		}
182 	}
183 
184 	override bool mouseEnterEvent(Vector2i p, bool enter)
185 	{
186 		if (!enter)
187 			_pushed_scroll_btn = false;
188 		return super.mouseEnterEvent(p, enter);
189 	}
190 
191 	/**
192 	 * The mouse button callback will return `true` when all three conditions are met:
193 	 *
194 	 * 1. This TreeView is "enabled" (see `nanogui.Widget.mEnabled`).
195 	 * 2. `p` is inside this TreeView.
196 	 * 3. `button` is `MouseButton.Left`.
197 	 *
198 	 * Since a mouse button event is issued for both when the mouse is pressed, as well
199 	 * as released, this function sets `nanogui.TreeView.mPushed` to `true` when
200 	 * parameter `down == true`.  When the second event (`down == false`) is fired,
201 	 * `nanogui.TreeView.mChecked` is inverted and `nanogui.TreeView.mCallback`
202 	 * is called.
203 	 *
204 	 * That is, the callback provided is only called when the mouse button is released,
205 	 * **and** the click location remains within the TreeView boundaries.  If the user
206 	 * clicks on the TreeView and releases away from the bounds of the TreeView,
207 	 * `nanogui.TreeView.mPushed` is simply set back to `false`.
208 	 */
209 	override bool mouseButtonEvent(Vector2i p, MouseButton button, bool down, int modifiers)
210 	{
211 		if (!mEnabled)
212 			return false;
213 
214 		if (!isMouseInside(p))
215 			return false;
216 
217 		static if (modelHasCollapsed)
218 		{
219 			import nanogui.experimental.utils : isPointInRect;
220 			const scroll_bar_available = _model.size > mSize.y;
221 			// get the area over the header of the widget
222 			auto header_area = Vector2i(mSize.x, cast(int)_model.header_size);
223 			if (scroll_bar_available)
224 				header_area.x -= ScrollBarWidth;
225 			const over_header_area = isPointInRect(mPos, header_area, p);
226 
227 			bool over_scroll_area;
228 			Vector2i scroll_area_pos;
229 			if (scroll_bar_available) // there is a scroll bar
230 			{
231 				scroll_area_pos = mPos + Vector2i(header_area.x, 0);
232 				auto scroll_area_size = Vector2i(ScrollBarWidth, mSize.y);
233 				over_scroll_area = isPointInRect(scroll_area_pos, scroll_area_size, p);
234 			}
235 			// if the event happens over neither the header nor scroll bar
236 			// nor any item - ignore it
237 			if (!over_header_area && !over_scroll_area && !tree_path.value.length)
238 				return false;
239 
240 			if (button == MouseButton.Left)
241 			{
242 				if (down)
243 				{
244 					if (over_scroll_area)
245 					{
246 						const scroll = scrollBtnSize;
247 						import nanogui.experimental.utils : isPointInRect;
248 						const rtopleft = scroll_area_pos + Vector2f(0, scroll.y);
249 						const rsize = Vector2f(ScrollBarWidth, scroll.h);
250 						if (isPointInRect(rtopleft, rsize, Vector2f(p)))
251 							_pushed_scroll_btn = true;
252 					}
253 					else
254 						mPushed = true;
255 				}
256 				else
257 				{
258 					if (mPushed)
259 					{
260 						if (!over_scroll_area)
261 						{
262 							const value = collapsed(tree_path.value[]);
263 							if (!value.isNull)
264 								collapsed(tree_path.value[], !value.get);
265 						}
266 						mPushed = false;
267 					}
268 					_pushed_scroll_btn = false;
269 				}
270 				if (button == MouseButton.Left && down && !mFocused)
271 					requestFocus();
272 				version(none) return true;          // <--- replaced by this
273 			}                                       //                    |
274 			                                        //                    |
275 			if (onMousePressed && down)             //                    |
276 				onMousePressed(button, tree_path);  //                    |
277 			                                        //                    |
278 			return button == MouseButton.Left;      //  <------------------
279 		}
280 		else
281 			return true;
282 	}
283 
284 	/// The preferred size of this TreeView.
285 	override Vector2i preferredSize(NanoContext ctx) const
286 	{
287 		// always return 0 because the size is defined by the parent container
288 		return Vector2i(0, 0);
289 	}
290 
291 	/// Draws this TreeView.
292 	override void draw(ref NanoContext ctx)
293 	{
294 		import auxil.location : SizeType;
295 
296 		ctx.save;
297 
298 		ctx.fontSize(theme.mButtonFontSize);
299 		ctx.fontFace("sans-bold");
300 
301 		const scroll_position = cast(size_t) (mScroll * (_model.size - size.y));
302 
303 		if (_scroll_position != scroll_position)
304 		{
305 			_scroll_position = scroll_position;
306 			visit(_model, _data, rm, cast(SizeType)_scroll_position);
307 		}
308 
309 		ctx.theme = theme;
310 		ctx.size = Vector2f(size.x, fontSize);
311 		if (_model.size > mSize.y)
312 			ctx.size.x -= ScrollBarWidth;
313 
314 		ctx.mouse += Vector2f(rm.loc.x.destination, rm.loc.y.destination) - mPos;
315 		scope(exit) ctx.mouse -= Vector2f(rm.loc.x.destination, rm.loc.y.destination) - mPos;
316 		ctx.translate(mPos.x, mPos.y);
317 		ctx.intersectScissor(0, 0, ctx.size.x, mSize.y);
318 		auto renderer = RenderingVisitor(ctx, [cast(SizeType) ctx.size.x, cast(SizeType) mSize.y]);
319 		renderer.loc.path = rm.loc.path;
320 		renderer.loc.x = rm.loc.x;
321 		renderer.loc.y = rm.loc.y;
322 		renderer.finish = rm.loc.y.destination + size.y;
323 		import nanogui.layout : Orientation;
324 		renderer.ctx.orientation = Orientation.Vertical;
325 		ctx.translate(-renderer.loc.x.destination, -renderer.loc.y.destination);
326 
327 		visit(_model, _data, renderer, rm.loc.y.destination + size.y + 50); // FIXME `+ 50` is dirty hack
328 		tree_path = renderer.selected_item;
329 
330 		ctx.restore;
331 
332 		if (_model.size > mSize.y)
333 			drawScrollBar(ctx);
334 	}
335 
336 	private ScrollButtonSize scrollBtnSize()
337 	{
338 		const float scrollh = max(16, height * min(1.0f, height / _model.size));
339 		return ScrollButtonSize((mSize.y - 8 - scrollh) * mScroll, scrollh);
340 	}
341 
342 	private void drawScrollBar(ref NanoContext ctx)
343 	{
344 		const scroll = scrollBtnSize;
345 
346 		// scroll bar
347 		NVGPaint paint = ctx.boxGradient(
348 			mPos.x + mSize.x - ScrollBarWidth + 1, mPos.y + 4 + 1, 8,
349 			mSize.y - 8, 3, 4, Color(0, 0, 0, 32), Color(0, 0, 0, 192));
350 		ctx.beginPath;
351 		ctx.roundedRect(mPos.x + mSize.x - ScrollBarWidth, mPos.y + 4, 8,
352 					mSize.y - 8, 3);
353 		ctx.fillPaint(paint);
354 		ctx.fill;
355 
356 		// scroll button
357 		paint = ctx.boxGradient(
358 			mPos.x + mSize.x - ScrollBarWidth - 1,
359 			mPos.y + 4 + scroll.y - 1, 8, scroll.h,
360 			3, 4, Color(220, 220, 220, 200), Color(128, 128, 128, 200));
361 
362 		ctx.beginPath;
363 		ctx.roundedRect(
364 			mPos.x + mSize.x - ScrollBarWidth + 1,
365 			mPos.y + 4 + 1 + scroll.y, 8 - 2,
366 			scroll.h - 2, 2);
367 		ctx.fillPaint(paint);
368 		ctx.fill;
369 	}
370 
371 // // Saves this TreeView to the specified Serializer.
372 //override void save(Serializer &s) const;
373 
374 // // Loads the state of the specified Serializer to this TreeView.
375 //override bool load(Serializer &s);
376 
377 protected:
378 
379 	static struct ScrollButtonSize
380 	{
381 		float y; // y position of the scroll button
382 		float h; // height of the scroll button
383 	}
384 
385 	import nanogui.experimental.utils : makeModel, visit, visitForward, TreePath;
386 
387 	enum ScrollBarWidth = 8;
388 	Data _data;
389 	typeof(makeModel(_data)) _model;
390 	RelativeMeasurer rm;
391 
392 	// sequence of indices to get access to current element of current treeview
393 	TreePath tree_path;
394 
395 	double mScroll;
396 	bool mPushed;
397 
398 	// y coordinate of first item
399 	size_t _scroll_position;
400 	size_t _start_item;
401 	size_t _finish_item;
402 	// y coordinate of the widget in space of first item
403 	size_t _shift;
404 	// defines if model size should be recalculated
405 	bool _model_changed;
406 	// if mouse left button has been pressed and not released over scroll button
407 	bool _pushed_scroll_btn;
408 }
409 
410 // This visitor renders the current visible elements
411 private struct RenderingVisitor
412 {
413 	import nanogui.experimental.utils : drawItem, indent, unindent, TreePath;
414 	import auxil.model;
415 	import auxil.default_visitor : TreePathVisitorImpl;
416 	import auxil.location : SizeType;
417 
418 	TreePathVisitorImpl!(typeof(this)) default_visitor;
419 	alias default_visitor this;
420 
421 	NanoContext ctx;
422 	TreePath selected_item;
423 	float finish;
424 	Vector2f origin;
425 
426 	this(ref NanoContext ctx, SizeType[2] size)
427 	{
428 		this.ctx = ctx;
429 		default_visitor = typeof(default_visitor)(size);
430 	}
431 
432 	bool complete()
433 	{
434 		return default_visitor.complete || ctx.position.y > finish;
435 	}
436 
437 	void beforeChildren(Order order, Data, Model)(ref const(Data) data, ref Model model)
438 	{
439 		ctx.indent;
440 	}
441 
442 	void afterChildren(Order order, Data, Model)(ref const(Data) data, ref Model model)
443 	{
444 		ctx.unindent;
445 	}
446 
447 	void enterTree(Order order, Data, Model)(ref const(Data) data, ref Model model)
448 	{
449 		origin = ctx.position;
450 		// origin = Vector2f(0, 0);
451 	}
452 
453 	~this()
454 	{
455 		ctx.position = origin;
456 	}
457 
458 	void enterNode(Order order, Data, Model)(ref const(Data) data, ref Model model)
459 		if (Model.Collapsable)
460 	{
461 		ctx.position.x = loc.x.position;
462 		ctx.position.y = loc.y.position;
463 		ctx.size[Orientation.Horizontal] = loc.x.size;
464 		ctx.size[Orientation.Vertical] = loc.y.size;
465 
466 		if (orientation == Orientation.Vertical)
467 		{
468 			// background for icon
469 			NVGPaint bg = ctx.boxGradient(
470 				ctx.position.x + 1.5f, ctx.position.y + 1.5f,
471 				ctx.size[ctx.orientation] - 2.0f, ctx.size[ctx.orientation] - 2.0f, 3, 3,
472 				true/*pushed*/ ? Color(0, 0, 0, 100) : Color(0, 0, 0, 32),
473 				Color(0, 0, 0, 180)
474 			);
475 
476 			ctx.beginPath;
477 			ctx.roundedRect(ctx.position.x + 1.0f, ctx.position.y + 1.0f,
478 				ctx.size[ctx.orientation] - 2.0f, ctx.size[ctx.orientation] - 2.0f, 3);
479 			ctx.fillPaint(bg);
480 			ctx.fill;
481 
482 			// icon
483 			ctx.fontSize(ctx.size.y);
484 			ctx.fontFace("icons");
485 			ctx.fillColor(model.enabled ? ctx.theme.mIconColor
486 			                            : ctx.theme.mDisabledTextColor);
487 			NVGTextAlign algn;
488 			algn.center = true;
489 			algn.middle = true;
490 			ctx.textAlign(algn);
491 
492 			import nanogui.entypo : Entypo;
493 			int axis2 = (cast(int)ctx.orientation+1)%2;
494 			const old = ctx.size[axis2];
495 			ctx.size[axis2] = ctx.size[ctx.orientation]; // icon has width equals to its height
496 			dchar[1] symb;
497 			symb[0] = model.collapsed ? Entypo.ICON_CHEVRON_RIGHT :
498 			                            Entypo.ICON_CHEVRON_DOWN;
499 			if (drawItem(ctx, ctx.size[ctx.orientation], symb[]))
500 				selected_item = loc.current_path;
501 			ctx.position.y += ctx.size.y;
502 			ctx.size[axis2] = old; // restore full width
503 			ctx.position[ctx.orientation] -= ctx.size[ctx.orientation];
504 
505 			// Caption
506 			ctx.position.x += 1.6f * ctx.size.y;
507 			scope(exit) ctx.position.x -= 1.6f * ctx.size.y;
508 			ctx.fontSize(ctx.size.y);
509 			ctx.fontFace("sans");
510 			ctx.fillColor(model.enabled ? ctx.theme.mTextColor : ctx.theme.mDisabledTextColor);
511 			if (drawItem(ctx, ctx.size.y, Data.stringof))
512 				selected_item = loc.current_path;
513 			ctx.position.y += ctx.size.y;
514 		}
515 	}
516 
517 	void enterNode(Order order, Data, Model)(ref const(Data) data, ref Model model)
518 		if (!Model.Collapsable)
519 	{
520 		ctx.position.x = loc.x.position;
521 		ctx.position.y = loc.y.position;
522 		ctx.size[Orientation.Horizontal] = loc.x.size;
523 		ctx.size[Orientation.Vertical] = loc.y.size;
524 
525 		ctx.fontSize(ctx.size.y);
526 		ctx.fontFace("sans");
527 		ctx.fillColor(ctx.theme.mTextColor);
528 		ctx.save;
529 		scope(exit) ctx.restore;
530 		ctx.intersectScissor(ctx.position.x, ctx.position.y, ctx.size.x, ctx.size.y);
531 		if (drawItem(ctx, cast(int) ctx.size[ctx.orientation], data))
532 			selected_item = loc.current_path;
533 		ctx.position.y += ctx.size.y;
534 	}
535 
536 	void leaveNode(Order order, Data, Model)(ref const(Data) data, ref Model model) {}
537 }
538 
539 // This visitor updates current path to the first visible element
540 alias RelativeMeasurer = TreePathVisitorImpl!();