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