1 /** 2 * A hierarchical tree view (`"SysTreeView32"`). 3 * 4 * `TreeView` wraps the Win32 tree-view common control, exposing root and child 5 * node insertion, selection get/set, per-item text and user data, and delegate 6 * events for selection changes and context-menu (right-click) requests. Nodes 7 * are referred to by the opaque `TreeItem` handle returned at insertion time. 8 */ 9 module deft.controls.treeview; 10 11 version (Windows): 12 13 import core.sys.windows.windows; 14 import core.sys.windows.commctrl; 15 16 import deft.controls.control; 17 import deft.events; 18 import deft.util.strings; 19 import deft.widget; 20 21 /// Opaque handle to a tree node. 22 struct TreeItem 23 { 24 /// The underlying Win32 tree-item handle. 25 HTREEITEM handle; 26 27 /// Whether this handle refers to no node. 28 bool isNull() const { return handle is null; } 29 30 /// Two `TreeItem`s are equal when they wrap the same handle. 31 bool opEquals(const TreeItem other) const { return handle is other.handle; } 32 } 33 34 /// A hierarchical tree of selectable, expandable nodes. 35 class TreeView : Control 36 { 37 /// Keeps GC-allocated item data reachable; see `setItemData`. 38 private void*[] retainedData_; 39 40 /// Fired when the selected node changes, carrying the newly selected item. 41 Event!(TreeItem) onSelectionChanged; 42 43 /** 44 * Fired when a context menu is requested, carrying the relevant item and the 45 * screen position to show the menu at. Raised both by a mouse right-click 46 * (item under the cursor) and by the keyboard — the Apps key or Shift+F10 — 47 * in which case the item is the selected node and the position is anchored to 48 * it. The screen coordinates can be passed straight to `showPopupMenu`. 49 */ 50 Event!(TreeItem, MouseEventArgs) onContextMenu; 51 52 /// Create a tree view inside `parent`. 53 this(Widget parent) 54 { 55 super(parent, "SysTreeView32", 56 TVS_HASLINES | TVS_LINESATROOT | TVS_HASBUTTONS | 57 TVS_SHOWSELALWAYS | WS_TABSTOP | WS_BORDER); 58 // Subclass so WM_CONTEXTMENU (mouse right-click and the Apps/Shift+F10 59 // keys) can be turned into onContextMenu — keyboard access is essential. 60 subclass(); 61 } 62 63 /// Insert a node captioned `text` as the last child of `parent`. 64 private TreeItem insert(HTREEITEM parent, string text) 65 { 66 TVINSERTSTRUCTW tis; 67 tis.hParent = parent; 68 tis.hInsertAfter = TVI_LAST; 69 tis.item.mask = TVIF_TEXT; 70 tis.item.pszText = cast(LPWSTR) text.toWStringz; 71 72 auto h = cast(HTREEITEM) SendMessageW(handle, TVM_INSERTITEMW, 0, 73 cast(LPARAM)&tis); 74 return TreeItem(h); 75 } 76 77 /// Add a top-level node captioned `text`. 78 TreeItem addRoot(string text) 79 { 80 return insert(TVI_ROOT, text); 81 } 82 83 /// Add a node captioned `text` as the last child of `parent`. 84 TreeItem addChild(TreeItem parent, string text) 85 { 86 return insert(parent.handle, text); 87 } 88 89 /// Remove every node (and release any retained item data; see `setItemData`). 90 void clear() 91 { 92 SendMessageW(handle, TVM_DELETEITEM, 0, cast(LPARAM) TVI_ROOT); 93 retainedData_ = null; 94 } 95 96 /// Get the currently selected node (a null `TreeItem` if none). 97 TreeItem getSelectedItem() 98 { 99 auto h = cast(HTREEITEM) SendMessageW(handle, TVM_GETNEXTITEM, 100 TVGN_CARET, 0); 101 return TreeItem(h); 102 } 103 104 /// Get the first top-level node (a null `TreeItem` if the tree is empty). 105 TreeItem getFirstRoot() 106 { 107 auto h = cast(HTREEITEM) SendMessageW(handle, TVM_GETNEXTITEM, 108 TVGN_ROOT, 0); 109 return TreeItem(h); 110 } 111 112 /// Select `item`. 113 void setSelectedItem(TreeItem item) 114 { 115 SendMessageW(handle, TVM_SELECTITEM, TVGN_CARET, cast(LPARAM) item.handle); 116 } 117 118 /// Get the caption text of `item`. 119 string getItemText(TreeItem item) 120 { 121 // Grow the buffer until the text is no longer truncated: the control fills 122 // up to cchTextMax-1 chars, so a result that long may have been clipped. 123 for (int cap = 256;; cap *= 2) 124 { 125 auto buf = new wchar[cap]; 126 TVITEMW tv; 127 tv.mask = TVIF_TEXT; 128 tv.hItem = item.handle; 129 tv.pszText = buf.ptr; 130 tv.cchTextMax = cap; 131 SendMessageW(handle, TVM_GETITEMW, 0, cast(LPARAM)&tv); 132 size_t len = 0; 133 while (len < cap && buf[len] != '\0') 134 ++len; 135 if (len < cap - 1 || cap >= 1 << 16) 136 return fromWString(buf[0 .. len]); 137 } 138 } 139 140 /** 141 * Associate an opaque `data` pointer with `item`. 142 * 143 * The pointer is stored inside the native control, where the D garbage 144 * collector cannot see it. To keep GC-allocated `data` from being collected 145 * out from under the control, Deft also retains a reference internally for the 146 * control's lifetime; the retained references are released by `clear()`. 147 */ 148 void setItemData(TreeItem item, void* data) 149 { 150 if (data !is null) 151 retainedData_ ~= data; 152 TVITEMW tv; 153 tv.mask = TVIF_PARAM; 154 tv.hItem = item.handle; 155 tv.lParam = cast(LPARAM) data; 156 SendMessageW(handle, TVM_SETITEMW, 0, cast(LPARAM)&tv); 157 } 158 159 /// Retrieve the opaque pointer previously stored with `setItemData`. 160 void* getItemData(TreeItem item) 161 { 162 TVITEMW tv; 163 tv.mask = TVIF_PARAM; 164 tv.hItem = item.handle; 165 SendMessageW(handle, TVM_GETITEMW, 0, cast(LPARAM)&tv); 166 return cast(void*) tv.lParam; 167 } 168 169 /// Expand `item` to reveal its children. 170 void expandItem(TreeItem item) 171 { 172 SendMessageW(handle, TVM_EXPAND, TVE_EXPAND, cast(LPARAM) item.handle); 173 } 174 175 /// Translate tree-view selection-change notifications into events. 176 override bool processNotify(NMHDR* header) 177 { 178 if (header.code == TVN_SELCHANGEDW) 179 { 180 auto nm = cast(NMTREEVIEWW*) header; 181 onSelectionChanged.fire(TreeItem(nm.itemNew.hItem)); 182 return true; 183 } 184 return false; 185 } 186 187 /** 188 * Turn `WM_CONTEXTMENU` into `onContextMenu`. The message is raised by a 189 * right-click and by the keyboard (Apps key / Shift+F10); the latter arrives 190 * with a position of `(-1, -1)`, in which case the menu is anchored at the 191 * selected node so a keyboard user gets the menu where focus is. 192 */ 193 override bool processSubclassed(UINT msg, WPARAM wParam, LPARAM lParam, 194 ref LRESULT result) 195 { 196 // On keyboard focus with nothing selected, select the first root node so 197 // a screen reader announces an item instead of silence. 198 if (msg == WM_SETFOCUS) 199 { 200 if (getSelectedItem().isNull) 201 { 202 auto first = getFirstRoot(); 203 if (!first.isNull) 204 setSelectedItem(first); 205 } 206 return false; 207 } 208 209 if (msg != WM_CONTEXTMENU) 210 return false; 211 212 immutable short sx = cast(short)(lParam & 0xFFFF); 213 immutable short sy = cast(short)((lParam >> 16) & 0xFFFF); 214 215 TreeItem item; 216 int x, y; 217 if (sx == -1 && sy == -1) 218 { 219 // Keyboard: anchor at the selected node (or the control's corner). 220 item = getSelectedItem(); 221 POINT pt; 222 if (!item.isNull) 223 { 224 RECT rc; 225 *(cast(HTREEITEM*)&rc) = item.handle; 226 if (SendMessageW(handle, TVM_GETITEMRECT, TRUE, cast(LPARAM)&rc)) 227 { 228 pt.x = rc.left; 229 pt.y = rc.bottom; 230 } 231 } 232 ClientToScreen(handle, &pt); 233 x = pt.x; 234 y = pt.y; 235 } 236 else 237 { 238 // Mouse: hit-test the click point to find the item under the cursor. 239 x = sx; 240 y = sy; 241 POINT pt = POINT(sx, sy); 242 ScreenToClient(handle, &pt); 243 TVHITTESTINFO ht; 244 ht.pt = pt; 245 item = TreeItem(cast(HTREEITEM) SendMessageW(handle, TVM_HITTEST, 0, 246 cast(LPARAM)&ht)); 247 } 248 249 onContextMenu.fire(item, MouseEventArgs(x, y, MouseButton.right)); 250 result = 0; 251 return true; 252 } 253 254 /// Tree views prefer a generously sized box. 255 override Size getPreferredSize() 256 { 257 return Size(200, 200); 258 } 259 }