diff --git a/CHANGELOG.md b/CHANGELOG.md index f7d5e6b..1506374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ in a major one. ## [Unreleased] +### Added + +- `NativeListView.SmallImageList` and `NativeListViewItem.ImageIndex` display an optional + image beside each row label. Image changes reach existing rows, and the association survives + control and image-list handle recreation. Image lists remain caller-owned and can be shared; + disposing an image list detaches it from the controls that use it. +- The NoteBook sample demonstrates row icons and a localized **Show note icons** toggle that + updates existing rows without rebuilding them. Its listening guide includes image, selection, + theme and language-switch checks. + ## [1.1.0] - 2026-09-08 ### Added diff --git a/README.md b/README.md index 08b5d4b..57702b9 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,20 @@ _noteMenu.Resolver = request => { }; ``` +## Row images + +Assign a WinForms `ImageList` to `NativeListView.SmallImageList`, then set each row's +`ImageIndex` to a zero-based image index. The default index, `-1`, displays no image. + +```csharp +networkList.SmallImageList = signalImages; +networkList.Items.Add(new NativeListViewItem("Home", "85%") { ImageIndex = 3 }); +``` + +The caller owns and disposes the image list; multiple controls may share it. The control +follows image-list handle recreation and detaches when the image list is disposed. Keep +meaningful image information in the row text as well, so screen readers can announce it. + ## What is it? WinForms 1.0 shipped `MainMenu` and `ContextMenu`, thin wrappers over the Win32 menu API. .NET 2.0 @@ -260,6 +274,10 @@ menu bar and two context menus. The layout exists for one reason: to make Tab mo list and ordinary WinForms controls, which is the path most likely to break, since the list is a real `SysListView32` inside a container WinForms does not own. +The rows use `SmallImageList` and `ImageIndex` for decorative note icons. **View → Show note +icons** changes the image index on existing rows, including `-1` for no image, while preserving +selection and focus. The form owns the image list and disposes it when it closes. + It also ships an English and a Hebrew catalog with a language switch in the View menu, because mirroring the layout while the text stays English tests very little. Switching to Hebrew puts right-to-left text in the menus, the column headers and the cells, puts Hebrew mnemonics through diff --git a/samples/NoteBook/LISTENING.md b/samples/NoteBook/LISTENING.md index 1964669..8dcc198 100644 --- a/samples/NoteBook/LISTENING.md +++ b/samples/NoteBook/LISTENING.md @@ -3,7 +3,7 @@ This is the acceptance test for the claims in the README, and it is written for whoever is evaluating, reviewing or contributing to this library — not for screen reader users specifically. The claims here are all of the form "a screen reader announces this correctly", -and neither the compiler nor the 136 automated tests can check a single one of them. Only +and neither the compiler nor the automated tests can check a single one of them. Only listening can. **You do not need to be a screen reader user to run this.** You need a screen reader running @@ -156,6 +156,23 @@ very little. Worth doing this on a machine whose Windows display language is Hebrew as well as on an English one; the two are not the same test. +## 8. Row images preserve the text and selection + +- In **All notes**, select a row and read its title, word count and modified date. Each row + has a decorative note icon; it conveys no information absent from the text. +- Open **View** and choose **Show note icons**. The checkmark should clear. The icons should + disappear, while the selected row, focus and scroll position stay put. Read the columns + again and confirm they still announce the same values. +- Turn **Show note icons** back on. The icons should return beside the existing row labels. +- Switch categories with icons off and then on; newly populated rows should follow the setting. +- Switch to **Hebrew** and back to **English** with icons enabled. The images should survive + the window recreation, and the row text should remain readable in each direction. +- For a visual check, repeat in dark mode and high contrast and confirm that the icons do not + obscure the labels or the selection highlight. Close the sample after switching language. + +These are manual checks to perform; the automated image tests verify native state and resource +lifetime, not appearance or screen-reader announcements. + ## Reporting Say which screen reader and version, which step, what you heard quoted as closely as you can, diff --git a/samples/NoteBook/MainForm.cs b/samples/NoteBook/MainForm.cs index 1b1b275..5f54586 100644 --- a/samples/NoteBook/MainForm.cs +++ b/samples/NoteBook/MainForm.cs @@ -24,6 +24,10 @@ namespace NoteBook; internal sealed class MainForm: Form { private readonly TreeView _categories = new(); private readonly NativeListView _notes = new(); + private readonly ImageList _noteImages = new() { + ColorDepth = ColorDepth.Depth32Bit, + ImageSize = new Size(16, 16), + }; private readonly TextBox _editor = new(); private readonly Label _categoriesLabel = new(); private readonly Label _notesLabel = new(); @@ -34,6 +38,7 @@ internal sealed class MainForm: Form { private NativeContextMenu? _columnMenu; private bool _showModified = true; + private bool _showImages = true; public MainForm() { // The manifest declares PerMonitorV2, so the form has to scale with the font or the @@ -46,6 +51,8 @@ public MainForm() { MinimumSize = new Size(640, 400); StartPosition = FormStartPosition.CenterScreen; + // A decorative note icon. Titles and columns carry all meaningful information. + _noteImages.Images.Add(SystemIcons.Application); BuildLayout(); Strings.Changed += OnLanguageChanged; ApplyLanguage(); @@ -75,6 +82,10 @@ protected override void Dispose(bool disposing) { _menuBar?.Dispose(); _noteMenu?.Dispose(); _columnMenu?.Dispose(); + + // NativeListView borrows its image list; the form owns and disposes it. + _notes.SmallImageList = null; + _noteImages.Dispose(); } base.Dispose(disposing); @@ -119,6 +130,7 @@ private void BuildLayout() { _notes.Dock = DockStyle.Fill; _notes.TabIndex = 3; _notes.MultiSelect = true; + _notes.SmallImageList = _noteImages; _notes.Columns.Add(new NativeListViewColumn("", NativeListViewColumn.AutoSizeToContent)); _notes.Columns.Add(new NativeListViewColumn("", 70, NativeColumnAlignment.Right)); _notes.Columns.Add(new NativeListViewColumn("", 140)); @@ -268,6 +280,7 @@ private IEnumerable SampleNotes() { words[key].ToString(), _showModified ? modified[key] : string.Empty) { Tag = Strings.Get($"note.{key}.body"), + ImageIndex = _showImages ? 0 : -1, }; } } @@ -300,6 +313,7 @@ private NativeMenuSpec BuildMenuSpec() => shortcutKeys: null, Close)) .AddMenu(Strings.Get("menu.view"), view => view .AddCheckable(Strings.Get("menu.view.modified"), _showModified, ToggleModifiedColumn) + .AddCheckable(Strings.Get("menu.view.images"), _showImages, ToggleNoteImages) .AddSeparator() .AddMenu(Strings.Get("menu.view.language"), language => language .AddRadio(Strings.Get("menu.view.language.english"), "language", @@ -364,6 +378,15 @@ private void ToggleModifiedColumn() { _menuBar?.Rebuild(BuildMenuSpec()); } + private void ToggleNoteImages() { + _showImages = !_showImages; + // Update existing rows so selection, focus and scroll position stay put. + foreach (var item in _notes.Items) { + item.ImageIndex = _showImages ? 0 : -1; + } + _menuBar?.Rebuild(BuildMenuSpec()); + } + private void ShowAbout() => MessageBox.Show( this, diff --git a/samples/NoteBook/Strings.cs b/samples/NoteBook/Strings.cs index 5da8c96..ee077c9 100644 --- a/samples/NoteBook/Strings.cs +++ b/samples/NoteBook/Strings.cs @@ -76,6 +76,7 @@ internal static string Get(string key) { ["menu.file.exit"] = "E&xit", ["menu.view"] = "&View", ["menu.view.modified"] = "Show &Modified column", + ["menu.view.images"] = "Show note &icons", ["menu.view.language"] = "&Language", ["menu.view.language.english"] = "&English", ["menu.view.language.hebrew"] = "&Hebrew", @@ -128,6 +129,7 @@ internal static string Get(string key) { ["menu.file.exit"] = "&יציאה", ["menu.view"] = "&תצוגה", ["menu.view.modified"] = "&הצג עמודת שינוי", + ["menu.view.images"] = "הצג &סמלי פתקים", ["menu.view.language"] = "&שפה", ["menu.view.language.english"] = "&אנגלית", ["menu.view.language.hebrew"] = "&עברית", diff --git a/src/Oire.WinForms.NativeControls/ListViewInterop.cs b/src/Oire.WinForms.NativeControls/ListViewInterop.cs index ce05cef..9099165 100644 --- a/src/Oire.WinForms.NativeControls/ListViewInterop.cs +++ b/src/Oire.WinForms.NativeControls/ListViewInterop.cs @@ -90,6 +90,11 @@ internal struct INITCOMMONCONTROLSEX { internal const uint LVS_REPORT = 0x0001; internal const uint LVS_SINGLESEL = 0x0004; + // ImageList owns its HIMAGELIST; destroying a list window must not destroy shared images. + internal const uint LVS_SHAREIMAGELISTS = 0x0040; + internal const int LVSIL_SMALL = 1; + internal const int I_IMAGENONE = -2; + /// /// Keep the selection visible when the control loses focus. Must be part of the creation /// style: setting it afterwards is ignored until the window is recreated. @@ -105,6 +110,8 @@ internal struct INITCOMMONCONTROLSEX { private const uint LVM_FIRST = 0x1000; + internal const uint LVM_GETIMAGELIST = LVM_FIRST + 2; + internal const uint LVM_SETIMAGELIST = LVM_FIRST + 3; internal const uint LVM_DELETEALLITEMS = LVM_FIRST + 9; internal const uint LVM_DELETEITEM = LVM_FIRST + 8; internal const uint LVM_GETITEMCOUNT = LVM_FIRST + 4; @@ -142,6 +149,7 @@ internal struct INITCOMMONCONTROLSEX { // --- Item and column fields ---------------------------------------------------------- internal const uint LVIF_TEXT = 0x0001; + internal const uint LVIF_IMAGE = 0x0002; internal const uint LVIF_STATE = 0x0008; internal const uint LVIF_PARAM = 0x0004; diff --git a/src/Oire.WinForms.NativeControls/NativeListView.cs b/src/Oire.WinForms.NativeControls/NativeListView.cs index 183fa7f..753f620 100644 --- a/src/Oire.WinForms.NativeControls/NativeListView.cs +++ b/src/Oire.WinForms.NativeControls/NativeListView.cs @@ -52,6 +52,7 @@ public class NativeListView: Control { private ChildMessageFilter? _childSubclass; private ChildDropTarget? _dropTarget; private bool _multiSelect; + private ImageList? _smallImageList; private BorderStyle _borderStyle = BorderStyle.Fixed3D; // Null means "not set", which is what separates the window default below from a caller @@ -109,6 +110,36 @@ public bool MultiSelect { } } + /// + /// Images displayed beside row labels, selected by . + /// The caller owns the image list and may share it between controls. Disposing this control + /// does not dispose the image list. Recreating either handle preserves the association. + /// + [DefaultValue(null)] + [Category("Behavior")] + [Description("Images displayed beside row labels. The caller owns the image list.")] + public ImageList? SmallImageList { + get => _smallImageList; + set { + if (ReferenceEquals(_smallImageList, value)) { + return; + } + + if (_smallImageList is not null) { + _smallImageList.RecreateHandle -= OnImageListRecreateHandle; + _smallImageList.Disposed -= OnImageListDisposed; + } + + _smallImageList = value; + if (_smallImageList is not null) { + _smallImageList.RecreateHandle += OnImageListRecreateHandle; + _smallImageList.Disposed += OnImageListDisposed; + } + + ApplySmallImageList(); + } + } + /// The selected rows, in display order. Empty when nothing is selected. [Browsable(false)] [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] @@ -551,6 +582,7 @@ protected override void WndProc(ref Message m) { /// protected override void Dispose(bool disposing) { if (disposing) { + SmallImageList = null; DestroyListWindow(); } @@ -589,6 +621,19 @@ internal void UpdateCell(int itemIndex, int cellIndex, string text) { } } + internal void UpdateItemImage(NativeListViewItem item) { + if (_listHandle == IntPtr.Zero || item.Index < 0) { + return; + } + + var native = new ListViewInterop.LVITEMW { + Mask = ListViewInterop.LVIF_IMAGE, + Item = item.Index, + Image = item.NativeImageIndex, + }; + ListViewInterop.SendMessageW(_listHandle, ListViewInterop.LVM_SETITEMW, IntPtr.Zero, ref native); + } + internal void RefreshItem(NativeListViewItem item) { if (_listHandle == IntPtr.Zero || item.Index < 0) { return; @@ -755,9 +800,10 @@ internal void InsertItemNative(int index, NativeListViewItem item) { var buffer = Marshal.StringToCoTaskMemUni(item.Cells.Count > 0 ? item.Cells[0] : string.Empty); try { var native = new ListViewInterop.LVITEMW { - Mask = ListViewInterop.LVIF_TEXT, + Mask = ListViewInterop.LVIF_TEXT | ListViewInterop.LVIF_IMAGE, Item = index, Text = buffer, + Image = item.NativeImageIndex, }; ListViewInterop.SendMessageW(_listHandle, ListViewInterop.LVM_INSERTITEMW, IntPtr.Zero, ref native); @@ -832,7 +878,8 @@ private void CreateListWindow() { EnsureCommonControls(); var style = ListViewInterop.WS_CHILD | ListViewInterop.WS_VISIBLE | - ListViewInterop.LVS_REPORT | ListViewInterop.LVS_SHOWSELALWAYS; + ListViewInterop.LVS_REPORT | ListViewInterop.LVS_SHOWSELALWAYS | + ListViewInterop.LVS_SHAREIMAGELISTS; if (!_multiSelect) { style |= ListViewInterop.LVS_SINGLESEL; @@ -870,6 +917,7 @@ private void CreateListWindow() { ApplyFont(); ApplyColors(); ApplyAccessibleName(); + ApplySmallImageList(); _childSubclass = new ChildMessageFilter(this); _childSubclass.AssignHandle(_listHandle); @@ -932,6 +980,20 @@ private void RecreateListWindow() { CreateListWindow(); } + private void ApplySmallImageList() { + if (_listHandle == IntPtr.Zero) { + return; + } + + ListViewInterop.SendMessageW(_listHandle, ListViewInterop.LVM_SETIMAGELIST, + ListViewInterop.LVSIL_SMALL, _smallImageList?.Handle ?? IntPtr.Zero); + Invalidate(true); + } + + private void OnImageListRecreateHandle(object? sender, EventArgs e) => ApplySmallImageList(); + + private void OnImageListDisposed(object? sender, EventArgs e) => SmallImageList = null; + private void ApplyFont() { if (_listHandle == IntPtr.Zero) { return; diff --git a/src/Oire.WinForms.NativeControls/NativeListViewItem.cs b/src/Oire.WinForms.NativeControls/NativeListViewItem.cs index abfc975..d0094db 100644 --- a/src/Oire.WinForms.NativeControls/NativeListViewItem.cs +++ b/src/Oire.WinForms.NativeControls/NativeListViewItem.cs @@ -8,6 +8,7 @@ namespace Oire.WinForms.NativeControls; /// public sealed class NativeListViewItem { private readonly CellCollection _cells; + private int _imageIndex = -1; /// Creates a row from its column texts, first column first. public NativeListViewItem(params string[] cells) { @@ -45,6 +46,27 @@ public Color? ForeColor { } } + /// + /// The zero-based image index in , or -1 for no image. + /// Changes update the attached row immediately. The index is retained when no image list is assigned. + /// + /// The value is less than -1. + public int ImageIndex { + get => _imageIndex; + set { + ArgumentOutOfRangeException.ThrowIfLessThan(value, -1); + if (_imageIndex == value) { + return; + } + + _imageIndex = value; + ListView?.UpdateItemImage(this); + } + } + + // -1 means a callback to Win32, whereas our public -1 means no image. + internal int NativeImageIndex => _imageIndex < 0 ? ListViewInterop.I_IMAGENONE : _imageIndex; + /// Application data. The control neither reads nor interprets it. public object? Tag { get; set; } diff --git a/tests/Oire.WinForms.NativeControls.Tests/NativeListViewImageTests.cs b/tests/Oire.WinForms.NativeControls.Tests/NativeListViewImageTests.cs new file mode 100644 index 0000000..67f6d78 --- /dev/null +++ b/tests/Oire.WinForms.NativeControls.Tests/NativeListViewImageTests.cs @@ -0,0 +1,135 @@ +using System.Runtime.InteropServices; +using System.Windows.Forms; +using AwesomeAssertions; +using Xunit; + +namespace Oire.WinForms.NativeControls.Tests; + +public class NativeListViewImageTests { + [Fact] + public void Images_BeforeAndAfterCreation_ReachNativeRows() { + StaRunner.Run(() => { + using var images = CreateImages(); + using var list = new NativeListView { SmallImageList = images }; + var row = new NativeListViewItem("Network", "85%") { ImageIndex = 1 }; + list.Columns.Add(new NativeListViewColumn("Name", 160)); + list.Items.Add(row); + list.Items.Add(new NativeListViewItem("No icon")); + images.HandleCreated.Should().BeFalse(); + _ = list.Handle; + + AttachedImages(list).Should().Be(images.Handle); + ReadImage(list, 0).Should().Be(1); + ReadImage(list, 1).Should().Be(-2); // I_IMAGENONE, not I_IMAGECALLBACK. + row.ImageIndex = 0; + ReadImage(list, 0).Should().Be(0); + row.ImageIndex = -1; + ReadImage(list, 0).Should().Be(-2); + list.Items.Add(new NativeListViewItem("Added later") { ImageIndex = 1 }); + ReadImage(list, 2).Should().Be(1); + }); + } + + [Fact] + public void Images_AcrossHandleRecreation_PreserveAssociationAndRows() { + StaRunner.Run(() => { + using var images = CreateImages(); + using var list = new RecreatableListView { SmallImageList = images }; + list.Columns.Add(new NativeListViewColumn("Name", 160)); + var row = new NativeListViewItem("Network") { ImageIndex = 1, Selected = true, Focused = true }; + list.Items.Add(row); + _ = list.Handle; + var imageHandle = images.Handle; + + list.RecreateForTest(); + AttachedImages(list).Should().Be(imageHandle); + ImageList_GetImageCount(imageHandle).Should().Be(2); + ReadImage(list, 0).Should().Be(1); + row.Selected.Should().BeTrue(); + row.Focused.Should().BeTrue(); + + // ColorDepth recreates the image-list handle and raises RecreateHandle. + images.ColorDepth = ColorDepth.Depth32Bit; + AttachedImages(list).Should().Be(images.Handle); + ReadImage(list, 0).Should().Be(1); + }); + } + + [Fact] + public void Images_AssignReplaceRemoveAndDispose_UpdateNativeAssociation() { + StaRunner.Run(() => { + using var first = CreateImages(); + using var second = CreateImages(); + using var list = new NativeListView(); + _ = list.Handle; + AttachedImages(list).Should().Be(IntPtr.Zero); + list.SmallImageList = first; + AttachedImages(list).Should().Be(first.Handle); + list.SmallImageList = second; + first.Dispose(); // The old image list must no longer affect this control. + AttachedImages(list).Should().Be(second.Handle); + list.SmallImageList = null; + AttachedImages(list).Should().Be(IntPtr.Zero); + ImageList_GetImageCount(second.Handle).Should().Be(2); + list.SmallImageList = second; + second.Dispose(); + list.SmallImageList.Should().BeNull(); + AttachedImages(list).Should().Be(IntPtr.Zero); + }); + } + + [Fact] + public void Images_SharedBetweenControls_OutliveEitherControl() { + StaRunner.Run(() => { + using var images = CreateImages(); + using var first = new NativeListView { SmallImageList = images }; + using var second = new NativeListView { SmallImageList = images }; + _ = first.Handle; + _ = second.Handle; + var imageHandle = images.Handle; + first.Dispose(); + AttachedImages(second).Should().Be(imageHandle); + ImageList_GetImageCount(imageHandle).Should().Be(2); + images.ColorDepth = ColorDepth.Depth32Bit; + first.SmallImageList.Should().BeNull(); + AttachedImages(second).Should().Be(images.Handle); + images.Dispose(); + second.SmallImageList.Should().BeNull(); + AttachedImages(second).Should().Be(IntPtr.Zero); + }); + } + + [Fact] + public void ImageIndex_DetachedRow_RetainsIndexAndRejectsInvalidNegativeValues() { + var row = new NativeListViewItem("Network"); + row.ImageIndex.Should().Be(-1); + row.ImageIndex = 4; + var act = () => row.ImageIndex = -2; + act.Should().Throw(); + row.ImageIndex.Should().Be(4); + } + + private static ImageList CreateImages() { + var images = new ImageList { ColorDepth = ColorDepth.Depth24Bit }; + images.Images.Add(SystemIcons.Information); + images.Images.Add(SystemIcons.Warning); + return images; + } + + private static IntPtr AttachedImages(NativeListView list) => ListViewInterop.SendMessageW( + list.ListHandle, ListViewInterop.LVM_GETIMAGELIST, ListViewInterop.LVSIL_SMALL, IntPtr.Zero); + + private static int ReadImage(NativeListView list, int index) { + var item = new ListViewInterop.LVITEMW { Mask = ListViewInterop.LVIF_IMAGE, Item = index }; + ListViewInterop.SendMessageW(list.ListHandle, ListViewInterop.LVM_GETITEMW, IntPtr.Zero, ref item) + .Should().NotBe(IntPtr.Zero); + return item.Image; + } + + [DllImport("comctl32.dll", ExactSpelling = true)] + private static extern int ImageList_GetImageCount(IntPtr imageList); + + private sealed class RecreatableListView: NativeListView { + internal void RecreateForTest() => RecreateHandle(); + } +}