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 }