Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion samples/NoteBook/LISTENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions samples/NoteBook/MainForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -268,6 +280,7 @@ private IEnumerable<NativeListViewItem> SampleNotes() {
words[key].ToString(),
_showModified ? modified[key] : string.Empty) {
Tag = Strings.Get($"note.{key}.body"),
ImageIndex = _showImages ? 0 : -1,
};
}
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions samples/NoteBook/Strings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"] = "&עברית",
Expand Down
8 changes: 8 additions & 0 deletions src/Oire.WinForms.NativeControls/ListViewInterop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// 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.
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
66 changes: 64 additions & 2 deletions src/Oire.WinForms.NativeControls/NativeListView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -109,6 +110,36 @@ public bool MultiSelect {
}
}

/// <summary>
/// Images displayed beside row labels, selected by <see cref="NativeListViewItem.ImageIndex"/>.
/// 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.
/// </summary>
[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();
}
}

/// <summary>The selected rows, in display order. Empty when nothing is selected.</summary>
[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
Expand Down Expand Up @@ -551,6 +582,7 @@ protected override void WndProc(ref Message m) {
/// <inheritdoc />
protected override void Dispose(bool disposing) {
if (disposing) {
SmallImageList = null;
DestroyListWindow();
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -870,6 +917,7 @@ private void CreateListWindow() {
ApplyFont();
ApplyColors();
ApplyAccessibleName();
ApplySmallImageList();

_childSubclass = new ChildMessageFilter(this);
_childSubclass.AssignHandle(_listHandle);
Expand Down Expand Up @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions src/Oire.WinForms.NativeControls/NativeListViewItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ namespace Oire.WinForms.NativeControls;
/// </summary>
public sealed class NativeListViewItem {
private readonly CellCollection _cells;
private int _imageIndex = -1;

/// <summary>Creates a row from its column texts, first column first.</summary>
public NativeListViewItem(params string[] cells) {
Expand Down Expand Up @@ -45,6 +46,27 @@ public Color? ForeColor {
}
}

/// <summary>
/// The zero-based image index in <see cref="NativeListView.SmallImageList"/>, or -1 for no image.
/// Changes update the attached row immediately. The index is retained when no image list is assigned.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">The value is less than -1.</exception>
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;

/// <summary>Application data. The control neither reads nor interprets it.</summary>
public object? Tag { get; set; }

Expand Down
Loading