1 /**
2  * Native report-mode list view (`SysListView32`).
3  *
4  * `ListView` wraps the Win32 list-view common control in report (details) mode:
5  * a grid of rows and columns with single selection, full-row select, grid lines
6  * and a clickable header. It exposes column and item management, selection,
7  * per-item user data, column ordering, and delegate-based events for selection
8  * changes, activation (double-click / Enter) and context-menu requests. Because
9  * it is a real native control, it brings MSAA accessibility for free.
10  */
11 module deft.controls.listview;
12 
13 version (Windows):
14 
15 import core.sys.windows.windows;
16 import core.sys.windows.commctrl;
17 
18 import deft.controls.control;
19 import deft.events;
20 import deft.util.strings;
21 import deft.widget;
22 
23 /// Horizontal alignment for a list-view column's text.
24 enum ColumnAlign
25 {
26 	/// Left-aligned (`LVCFMT_LEFT`).
27 	left,
28 	/// Center-aligned (`LVCFMT_CENTER`).
29 	center,
30 	/// Right-aligned (`LVCFMT_RIGHT`).
31 	right,
32 }
33 
34 /**
35  * How a column should auto-size itself to fit, for `ListView.autoSizeColumn`.
36  *
37  * This is the analog of WinForms' negative column-width sentinels: `content`
38  * matches `-1` (fit the data) and `header` matches `-2` (fit the header text).
39  */
40 enum ColumnAutoSize
41 {
42 	/// Fit the widest cell in the column (`LVSCW_AUTOSIZE`; WinForms `-1`).
43 	content,
44 	/**
45 	 * Fit the header text (`LVSCW_AUTOSIZE_USEHEADER`; WinForms `-2`). Applied to
46 	 * the *last* column the native control instead stretches it to fill the list's
47 	 * remaining width — the usual way to make a final column absorb the slack.
48 	 */
49 	header,
50 }
51 
52 /// Map a `ColumnAlign` to its `LVCFMT_*` flag.
53 private int columnAlignFmt(ColumnAlign align_) @safe pure nothrow @nogc
54 {
55 	final switch (align_)
56 	{
57 		case ColumnAlign.left:
58 			return LVCFMT_LEFT;
59 		case ColumnAlign.center:
60 			return LVCFMT_CENTER;
61 		case ColumnAlign.right:
62 			return LVCFMT_RIGHT;
63 	}
64 }
65 
66 /// A native list view in report (details) mode.
67 class ListView : Control
68 {
69 	private int columnCount_;
70 
71 	/// Keeps GC-allocated item data reachable; see `setItemData`.
72 	private void*[] retainedData_;
73 
74 	/// Fired when the selected row changes; argument is the new selected index.
75 	Event!(int) onSelectionChanged;
76 	/// Fired when a row is activated (double-click or Enter); argument is the row index.
77 	Event!(int) onItemActivated;
78 	/**
79 	 * Fired when a context menu is requested, carrying the relevant row index
80 	 * (-1 if none) and the screen position to show the menu at. Raised both by a
81 	 * mouse right-click (row under the cursor) and by the keyboard — the Apps key
82 	 * or Shift+F10 — in which case the row is the selected one and the position is
83 	 * anchored to it. The screen coordinates can be passed to `showPopupMenu`.
84 	 */
85 	Event!(int, MouseEventArgs) onContextMenu;
86 
87 	/**
88 	 * Create a report-mode list view as a child of `parent`.
89 	 *
90 	 * The control is single-select, always shows the selection, takes part in tab
91 	 * navigation and has a border. Full-row selection and grid lines are enabled.
92 	 */
93 	this(Widget parent)
94 	{
95 		super(parent, "SysListView32",
96 			LVS_REPORT | LVS_SINGLESEL | LVS_SHOWSELALWAYS | WS_TABSTOP | WS_BORDER);
97 
98 		SendMessageW(handle, LVM_SETEXTENDEDLISTVIEWSTYLE, 0,
99 			LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES);
100 
101 		// Subclass so WM_CONTEXTMENU (mouse right-click and the Apps/Shift+F10
102 		// keys) can be turned into onContextMenu — keyboard access is essential.
103 		subclass();
104 	}
105 
106 	/**
107 	 * Append a column with the given header `title`, pixel `width` and text
108 	 * alignment. Returns the new column's index.
109 	 */
110 	int addColumn(string title, int width, ColumnAlign align_ = ColumnAlign.left)
111 	{
112 		LVCOLUMNW col;
113 		col.mask = LVCF_TEXT | LVCF_WIDTH | LVCF_FMT | LVCF_SUBITEM;
114 		col.fmt = columnAlignFmt(align_);
115 		col.cx = width;
116 		col.pszText = cast(LPWSTR) title.toWStringz;
117 		col.iSubItem = columnCount_;
118 
119 		SendMessageW(handle, LVM_INSERTCOLUMNW, columnCount_, cast(LPARAM)&col);
120 		return columnCount_++;
121 	}
122 
123 	/// Change the header text of column `col` (e.g. when the UI language changes).
124 	void setColumnTitle(int col, string title)
125 	{
126 		LVCOLUMNW c;
127 		c.mask = LVCF_TEXT;
128 		c.pszText = cast(LPWSTR) title.toWStringz;
129 		SendMessageW(handle, LVM_SETCOLUMNW, col, cast(LPARAM)&c);
130 	}
131 
132 	/// Set the pixel width of column `col`. For autosizing, use `autoSizeColumn`.
133 	void setColumnWidth(int col, int width)
134 	{
135 		SendMessageW(handle, LVM_SETCOLUMNWIDTH, col, width);
136 	}
137 
138 	/**
139 	 * Size column `col` to fit its content (`ColumnAutoSize.content`) or its header
140 	 * text (`ColumnAutoSize.header`) — the equivalents of WinForms' `-1` and `-2`
141 	 * column widths.
142 	 *
143 	 * This is a *one-shot* measurement of the column's current contents, not a
144 	 * persistent mode: call it **after** the rows are populated, and again if the
145 	 * data changes. As a special case, `ColumnAutoSize.header` on the last column
146 	 * stretches that column to fill the list's remaining width, so a common recipe
147 	 * is fixed/auto widths for the leading columns and `header` on the last.
148 	 */
149 	void autoSizeColumn(int col, ColumnAutoSize mode = ColumnAutoSize.content)
150 	{
151 		immutable int sentinel = mode == ColumnAutoSize.header
152 			? LVSCW_AUTOSIZE_USEHEADER : LVSCW_AUTOSIZE;
153 		SendMessageW(handle, LVM_SETCOLUMNWIDTH, col, sentinel);
154 	}
155 
156 	/// Get the pixel width of column `col`.
157 	int getColumnWidth(int col)
158 	{
159 		return cast(int) SendMessageW(handle, LVM_GETCOLUMNWIDTH, col, 0);
160 	}
161 
162 	/**
163 	 * Append a row. `cells[0]` is the main item text; `cells[1 .. $]` fill the
164 	 * subsequent columns. Returns the new row index, or -1 if `cells` is empty.
165 	 */
166 	int addItem(string[] cells)
167 	{
168 		if (cells.length == 0)
169 			return -1;
170 
171 		LVITEMW item;
172 		item.mask = LVIF_TEXT;
173 		item.iItem = getItemCount();
174 		item.iSubItem = 0;
175 		item.pszText = cast(LPWSTR) cells[0].toWStringz;
176 
177 		int row = cast(int) SendMessageW(handle, LVM_INSERTITEMW, 0, cast(LPARAM)&item);
178 
179 		foreach (c; 1 .. cells.length)
180 		{
181 			LVITEMW sub;
182 			sub.iSubItem = cast(int) c;
183 			sub.pszText = cast(LPWSTR) cells[c].toWStringz;
184 			SendMessageW(handle, LVM_SETITEMTEXTW, row, cast(LPARAM)&sub);
185 		}
186 
187 		return row;
188 	}
189 
190 	/// Remove all rows (and release any retained item data; see `setItemData`).
191 	void clear()
192 	{
193 		SendMessageW(handle, LVM_DELETEALLITEMS, 0, 0);
194 		retainedData_ = null;
195 	}
196 
197 	/// Return the number of rows.
198 	int getItemCount()
199 	{
200 		return cast(int) SendMessageW(handle, LVM_GETITEMCOUNT, 0, 0);
201 	}
202 
203 	/// Return the index of the selected row, or -1 if nothing is selected.
204 	int getSelectedIndex()
205 	{
206 		return cast(int) SendMessageW(handle, LVM_GETNEXTITEM, cast(WPARAM)-1, LVNI_SELECTED);
207 	}
208 
209 	/// Select (and focus) the row at `index`.
210 	void setSelectedIndex(int index)
211 	{
212 		LVITEMW item;
213 		item.state = LVIS_SELECTED | LVIS_FOCUSED;
214 		item.stateMask = LVIS_SELECTED | LVIS_FOCUSED;
215 		SendMessageW(handle, LVM_SETITEMSTATE, index, cast(LPARAM)&item);
216 	}
217 
218 	/// Scroll the row at `index` into view.
219 	void ensureVisible(int index)
220 	{
221 		SendMessageW(handle, LVM_ENSUREVISIBLE, index, FALSE);
222 	}
223 
224 	/// Return the text of the cell at the given `row` and `col`.
225 	string getItemText(int row, int col)
226 	{
227 		// LVM_GETITEMTEXTW returns the number of characters copied; a result that
228 		// fills the buffer (cap-1) may have been truncated, so grow and retry.
229 		for (int cap = 256;; cap *= 2)
230 		{
231 			auto buf = new wchar[cap];
232 			LVITEMW item;
233 			item.iSubItem = col;
234 			item.pszText = buf.ptr;
235 			item.cchTextMax = cap;
236 			int got = cast(int) SendMessageW(handle, LVM_GETITEMTEXTW, row,
237 				cast(LPARAM)&item);
238 			if (got < cap - 1 || cap >= 1 << 16)
239 				return fromWString(buf[0 .. got]);
240 		}
241 	}
242 
243 	/**
244 	 * Associate an opaque user pointer with the row at `index`.
245 	 *
246 	 * The pointer is stored inside the native control, where the D garbage
247 	 * collector cannot see it. To keep GC-allocated `data` from being collected
248 	 * out from under the control, Deft also retains a reference internally for the
249 	 * control's lifetime; the retained references are released by `clear()`. (The
250 	 * native control owns the canonical copy returned by `getItemData`.)
251 	 */
252 	void setItemData(int index, void* data)
253 	{
254 		if (data !is null)
255 			retainedData_ ~= data;
256 		LVITEMW item;
257 		item.mask = LVIF_PARAM;
258 		item.iItem = index;
259 		item.lParam = cast(LPARAM) data;
260 		SendMessageW(handle, LVM_SETITEM, 0, cast(LPARAM)&item);
261 	}
262 
263 	/// Retrieve the user pointer associated with the row at `index`.
264 	void* getItemData(int index)
265 	{
266 		LVITEMW item;
267 		item.mask = LVIF_PARAM;
268 		item.iItem = index;
269 		SendMessageW(handle, LVM_GETITEM, 0, cast(LPARAM)&item);
270 		return cast(void*) item.lParam;
271 	}
272 
273 	/// Set the left-to-right display order of the columns.
274 	void setColumnsOrder(int[] order)
275 	{
276 		SendMessageW(handle, LVM_SETCOLUMNORDERARRAY, order.length, cast(LPARAM) order.ptr);
277 	}
278 
279 	/// Get the current left-to-right display order of the columns.
280 	int[] getColumnsOrder()
281 	{
282 		auto arr = new int[columnCount_];
283 		SendMessageW(handle, LVM_GETCOLUMNORDERARRAY, columnCount_, cast(LPARAM) arr.ptr);
284 		return arr;
285 	}
286 
287 	/// Route list-view selection and activation notifications to their events.
288 	override bool processNotify(NMHDR* header)
289 	{
290 		switch (header.code)
291 		{
292 			case LVN_ITEMCHANGED:
293 				auto nm = cast(NMLISTVIEW*) header;
294 				if ((nm.uChanged & LVIF_STATE)
295 					&& (nm.uNewState & LVIS_SELECTED)
296 					&& !(nm.uOldState & LVIS_SELECTED))
297 					onSelectionChanged.fire(nm.iItem);
298 				return true;
299 
300 			case LVN_ITEMACTIVATE:
301 				auto nm = cast(NMITEMACTIVATE*) header;
302 				onItemActivated.fire(nm.iItem);
303 				return true;
304 
305 			default:
306 				return false;
307 		}
308 	}
309 
310 	/**
311 	 * Turn `WM_CONTEXTMENU` into `onContextMenu`. The message is raised by a
312 	 * right-click and by the keyboard (Apps key / Shift+F10); the latter arrives
313 	 * with a position of `(-1, -1)`, in which case the menu is anchored at the
314 	 * selected row so a keyboard user gets the menu where focus is.
315 	 */
316 	override bool processSubclassed(UINT msg, WPARAM wParam, LPARAM lParam,
317 		ref LRESULT result)
318 	{
319 		// On keyboard focus with nothing selected, select the first row so a
320 		// screen reader announces an item instead of silence.
321 		if (msg == WM_SETFOCUS)
322 		{
323 			if (getSelectedIndex() < 0 && getItemCount() > 0)
324 				setSelectedIndex(0);
325 			return false;
326 		}
327 
328 		if (msg != WM_CONTEXTMENU)
329 			return false;
330 
331 		immutable short sx = cast(short)(lParam & 0xFFFF);
332 		immutable short sy = cast(short)((lParam >> 16) & 0xFFFF);
333 
334 		int index;
335 		int x, y;
336 		if (sx == -1 && sy == -1)
337 		{
338 			// Keyboard: anchor at the selected row (or the control's corner).
339 			index = getSelectedIndex();
340 			POINT pt;
341 			RECT rc;
342 			rc.left = LVIR_LABEL;
343 			if (index >= 0
344 				&& SendMessageW(handle, LVM_GETITEMRECT, index, cast(LPARAM)&rc))
345 			{
346 				pt.x = rc.left;
347 				pt.y = rc.bottom;
348 			}
349 			ClientToScreen(handle, &pt);
350 			x = pt.x;
351 			y = pt.y;
352 		}
353 		else
354 		{
355 			// Mouse: hit-test the click point to find the row under the cursor.
356 			x = sx;
357 			y = sy;
358 			POINT pt = POINT(sx, sy);
359 			ScreenToClient(handle, &pt);
360 			LVHITTESTINFO ht;
361 			ht.pt = pt;
362 			index = cast(int) SendMessageW(handle, LVM_HITTEST, 0, cast(LPARAM)&ht);
363 		}
364 
365 		onContextMenu.fire(index, MouseEventArgs(x, y, MouseButton.right));
366 		result = 0;
367 		return true;
368 	}
369 
370 	/// A sensible default size for a report-mode list.
371 	override Size getPreferredSize()
372 	{
373 		return Size(300, 200);
374 	}
375 }