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 }