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 }