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;
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.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(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 		ctx.save;
295 
296 		ctx.fontSize(theme.mButtonFontSize);
297 		ctx.fontFace("sans-bold");
298 
299 		const scroll_position = cast(size_t) (mScroll * (_model.size - size.y));
300 
301 		if (_scroll_position != scroll_position)
302 		{
303 			_scroll_position = scroll_position;
304 			visit(_model, _data, rm, _scroll_position);
305 		}
306 
307 		ctx.theme = theme;
308 		ctx.size = Vector2f(size.x, fontSize);
309 		if (_model.size > mSize.y)
310 			ctx.size.x -= ScrollBarWidth;
311 		ctx.position.x = 0;
312 		ctx.position.y = rm.position - rm.destination;
313 
314 		ctx.mouse -= mPos;
315 		scope(exit) ctx.mouse += mPos;
316 		ctx.translate(mPos.x, mPos.y);
317 		ctx.intersectScissor(0, 0, ctx.size.x, mSize.y);
318 		auto renderer = RenderingVisitor(ctx);
319 		renderer.path = rm.path;
320 		renderer.position = rm.position;
321 		renderer.finish = rm.destination + size.y;
322 		import nanogui.layout : Orientation;
323 		renderer.ctx.orientation = Orientation.Vertical;
324 		visit(_model, _data, renderer, rm.destination + size.y + 50); // FIXME `+ 50` is dirty hack
325 		tree_path = renderer.selected_item;
326 
327 		ctx.restore;
328 
329 		if (_model.size > mSize.y)
330 			drawScrollBar(ctx);
331 	}
332 
333 	private ScrollButtonSize scrollBtnSize()
334 	{
335 		const float scrollh = max(16, height * min(1.0f, height / _model.size));
336 		return ScrollButtonSize((mSize.y - 8 - scrollh) * mScroll, scrollh);
337 	}
338 
339 	private void drawScrollBar(ref NanoContext ctx)
340 	{
341 		const scroll = scrollBtnSize;
342 
343 		// scroll bar
344 		NVGPaint paint = ctx.boxGradient(
345 			mPos.x + mSize.x - ScrollBarWidth + 1, mPos.y + 4 + 1, 8,
346 			mSize.y - 8, 3, 4, Color(0, 0, 0, 32), Color(0, 0, 0, 192));
347 		ctx.beginPath;
348 		ctx.roundedRect(mPos.x + mSize.x - ScrollBarWidth, mPos.y + 4, 8,
349 					mSize.y - 8, 3);
350 		ctx.fillPaint(paint);
351 		ctx.fill;
352 
353 		// scroll button
354 		paint = ctx.boxGradient(
355 			mPos.x + mSize.x - ScrollBarWidth - 1,
356 			mPos.y + 4 + scroll.y - 1, 8, scroll.h,
357 			3, 4, Color(220, 220, 220, 200), Color(128, 128, 128, 200));
358 
359 		ctx.beginPath;
360 		ctx.roundedRect(
361 			mPos.x + mSize.x - ScrollBarWidth + 1,
362 			mPos.y + 4 + 1 + scroll.y, 8 - 2,
363 			scroll.h - 2, 2);
364 		ctx.fillPaint(paint);
365 		ctx.fill;
366 	}
367 
368 // // Saves this TreeView to the specified Serializer.
369 //override void save(Serializer &s) const;
370 
371 // // Loads the state of the specified Serializer to this TreeView.
372 //override bool load(Serializer &s);
373 
374 protected:
375 
376 	static struct ScrollButtonSize
377 	{
378 		float y; // y position of the scroll button
379 		float h; // height of the scroll button
380 	}
381 
382 	import nanogui.experimental.utils : makeModel, visit, visitForward, TreePath;
383 
384 	enum ScrollBarWidth = 8;
385 	Data _data;
386 	typeof(makeModel(_data)) _model;
387 	RelativeMeasurer rm;
388 
389 	// sequence of indices to get access to current element of current treeview
390 	TreePath tree_path;
391 
392 	double mScroll;
393 	bool mPushed;
394 
395 	// y coordinate of first item
396 	size_t _scroll_position;
397 	size_t _start_item;
398 	size_t _finish_item;
399 	// y coordinate of the widget in space of first item
400 	size_t _shift;
401 	// if model size should be recalculated
402 	bool _model_changed;
403 	// if mouse left button has been pressed and not released over scroll button
404 	bool _pushed_scroll_btn;
405 }
406 
407 // This visitor renders the current visible elements
408 private struct RenderingVisitor
409 {
410 	import nanogui.experimental.utils : drawItem, indent, unindent, TreePath;
411 	import auxil.model;
412 
413 	NanoContext ctx;
414 	DefaultVisitorImpl!(SizeEnabled.no, TreePathEnabled.yes) default_visitor;
415 	alias default_visitor this;
416 
417 	TreePath selected_item;
418 	float finish;
419 
420 	bool complete()
421 	{
422 		return ctx.position.y > finish;
423 	}
424 
425 	void indent()
426 	{
427 		ctx.indent;
428 	}
429 
430 	void unindent()
431 	{
432 		ctx.unindent;
433 	}
434 
435 	void enterNode(Order order, Data, Model)(ref const(Data) data, ref Model model)
436 	{
437 		ctx.save;
438 		scope(exit) ctx.restore;
439 		version(none)
440 		{
441 			ctx.strokeWidth(1.0f);
442 			ctx.beginPath;
443 			ctx.rect(ctx.position.x + 1.0f, ctx.position.y + 1.0f, ctx.size.x - 2, model.size-2);
444 			ctx.strokeColor(Color(255, 0, 0, 255));
445 			ctx.stroke;
446 		}
447 
448 		{
449 			// background for icon
450 			NVGPaint bg = ctx.boxGradient(
451 				ctx.position.x + 1.5f, ctx.position.y + 1.5f,
452 				ctx.size[ctx.orientation] - 2.0f, ctx.size[ctx.orientation] - 2.0f, 3, 3,
453 				true/*pushed*/ ? Color(0, 0, 0, 100) : Color(0, 0, 0, 32),
454 				Color(0, 0, 0, 180)
455 			);
456 
457 			ctx.beginPath;
458 			ctx.roundedRect(ctx.position.x + 1.0f, ctx.position.y + 1.0f,
459 				ctx.size[ctx.orientation] - 2.0f, ctx.size[ctx.orientation] - 2.0f, 3);
460 			ctx.fillPaint(bg);
461 			ctx.fill;
462 		}
463 
464 		{
465 			// icon
466 			ctx.fontSize(ctx.size.y);
467 			ctx.fontFace("icons");
468 			ctx.fillColor(model.enabled ? ctx.theme.mIconColor
469 			                            : ctx.theme.mDisabledTextColor);
470 			NVGTextAlign algn;
471 			algn.center = true;
472 			algn.middle = true;
473 			ctx.textAlign(algn);
474 
475 			import nanogui.entypo : Entypo;
476 			int axis2 = (cast(int)ctx.orientation+1)%2;
477 			const old = ctx.size[axis2];
478 			ctx.size[axis2] = ctx.size[ctx.orientation]; // icon has width equals to its height
479 			dchar[1] symb;
480 			symb[0] = model.collapsed ? Entypo.ICON_CHEVRON_RIGHT :
481 			                            Entypo.ICON_CHEVRON_DOWN;
482 			if (drawItem(ctx, ctx.size[ctx.orientation], symb[]))
483 				selected_item = tree_path;
484 			ctx.size[axis2] = old; // restore full width
485 			ctx.position[ctx.orientation] -= ctx.size[ctx.orientation];
486 		}
487 
488 		{
489 			// Caption
490 			const shift = 1.6f * ctx.size.y;
491 			ctx.position.x += shift;
492 			ctx.size.x -= shift;
493 			scope(exit)
494 			{
495 				ctx.position.x -= shift;
496 				ctx.size.x += shift;
497 			}
498 			ctx.fontSize(ctx.size.y);
499 			ctx.fontFace("sans");
500 			ctx.fillColor(model.enabled ? ctx.theme.mTextColor : ctx.theme.mDisabledTextColor);
501 
502 			import nanogui.experimental.utils : hasRenderHeader;
503 			static if (hasRenderHeader!data)
504 			{
505 				import auxil.model : FixedAppender;
506 				FixedAppender!512 app;
507 				data.renderHeader(app);
508 				auto header = app[];
509 			}
510 			else
511 				auto header = Data.stringof;
512 			if (drawItem(ctx, model.header_size, header))
513 				selected_item = tree_path;
514 		}
515 	}
516 
517 	void processLeaf(Order order, Data, Model)(ref const(Data) data, ref Model model)
518 	{
519 		ctx.save;
520 		scope(exit) ctx.restore;
521 		version(none)
522 		{
523 			ctx.strokeWidth(1.0f);
524 			ctx.beginPath;
525 			ctx.rect(ctx.position.x + 1.0f, ctx.position.y + 1.0f, ctx.size.x - 2, model.size - 2);
526 			ctx.strokeColor(Color(255, 0, 0, 255));
527 			ctx.stroke;
528 		}
529 		ctx.fontSize(ctx.size.y);
530 		ctx.fontFace("sans");
531 		ctx.fillColor(ctx.theme.mTextColor);
532 		if (drawItem(ctx, model.size, data))
533 			selected_item = tree_path;
534 	}
535 }
536 
537 // This visitor updates current path to the first visible element
538 struct RelativeMeasurer
539 {
540 	import auxil.model;
541 
542 	alias DefVisitor = DefaultVisitorImpl!(SizeEnabled.no, TreePathEnabled.yes);
543 	DefVisitor default_visitor;
544 	alias default_visitor this;
545 }