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!();