2

Sometimes things (PowerShell?) gets too smart on me...

In this case, I would like change the Text property of a WinForms ListBox item selected by its index (not the SelectedItems). The point here is that $ListBox.Items[$CurrentIndex] appears to be a [string] type rather than an [Item] type...
For this I have created a mcve (with a context menu) out of my bigger project:

using namespace System.Windows.Forms
$Form = [Form]@{ StartPosition = 'CenterScreen' }
$ListBox = [ListBox]@{}
@('one', 'two', 'three').ForEach{ $Null = $ListBox.Items.Add($_) }
$Form.Controls.Add($ListBox)
$ListBox.ContextMenuStrip = [ContextMenuStrip]@{}
$Context = $ListBox.ContextMenuStrip.Items.Add('ToUpper')
$ListBox.Add_MouseDown({ param($s, $e) 
    if ($e.Button -eq 'Right') { $Script:CurrentIndex = $ListBox.IndexFromPoint($e.Location) }
})
$Context.Add_Click({ param($s, $e)
    if ($Null -ne $CurrentIndex) {
        $Text = $ListBox.Items[$CurrentIndex]
        Write-Host "I want to change text ""$Text"" of the item #$CurrentIndex to: ""$($Text.ToUpper())"""
        # $ListBox.Items.Item[$CurrentIndex] = $Text.ToUpper()
    }
})
$Form.ShowDialog()

How can I change the text of e.g. $ListBox.Items[1] ("two") to e.g. "TWO"?
I am actually not sure whether this is PowerShell related issue (similar to #16878 Decorate dot selected Xml strings (leaves) with XmlElement methods where I would have an option to use the SelectNodes() method) or related to WinForms itself: The answer might be in Gwt Listbox item reference to an Object in which case I have no clue how translate this to PowerShell.

iRon
  • 20,463
  • 10
  • 53
  • 79

2 Answers2

3

You could modify the stored item value with $ListBox.Items.Item($CurrentIndex) = $Text.ToUpper() - but modifying a collection item in place is not going to trigger re-drawing the ListBox control, so it'll look like no change took place.

Instead, modify the collection itself - remove the old entry and insert a new one at the same position:

$Context.Add_Click({ param($s, $e)
    if ($Null -ne $CurrentIndex) {
        $Text = $ListBox.Items[$CurrentIndex]
        # Remove clicked item
        $ListBox.Items.RemoveAt($CurrentIndex)
        # Insert uppercased string value as new item at the same index
        $ListBox.Items.Insert($CurrentIndex, $Text.ToUpper())
    }
})

This will cause the owning control to refresh and the change will be reflected in the GUI immediately

Mathias R. Jessen
  • 157,619
  • 12
  • 148
  • 206
  • Thanks both for your insights and fixes. As mentioned by [mklement0](https://stackoverflow.com/users/45375/mklement0), both workarounds have their (dis)advantages. In this solution, reinserting the item causes the **selection state** to be lost (as in the other solution, it looses **the scroll state**). This can be worked around by capturing the state (`$Selected = $ListBox.GetSelected($CurrentIndex)`) and reapplying it: (`$ListBox.SetSelected($CurrentIndex, $Selected)`) after the reinsert. – iRon Feb 24 '22 at 06:47
  • @iRon, you may not always see it, but deleting and re-adding can change the scroll state too, so you must save and restore `.TopIndex` as well. If either of you has any insight into why item-specific refreshing doesn't work, i.e. why calling `.ResetItem($CurrentIndex)` on a `BindingList[string]` instance serving as the `.DataSource` has no effect, please let me know. – mklement0 Feb 24 '22 at 13:05
2

To complement Mathias R. Jessen's helpful answer:

Indeed, the problem isn't with the modification of the .Items collection itself - $ListBox.Items[$CurrentIndex] = $Text.ToUpper() works just fine.[1]

To address the real problem - the ListBox not refreshing in response to modifying an existing item in the underlying .Items collection in place (not even if you call $ListBox.Refresh()), there's an alternative to removing and re-adding the item:

You can use data binding, by assigning a System.ComponentModel.BindingList`1 instance to the list box's .DataSource property.

After modifying an item in place, you can call the .ResetBindings() method to get the listbox to refresh itself, as shown below.

Note:

  • There's also a .ResetItem(int index) method designed to perform item-specific refreshing, but I couldn't get it to work; pointers as to why are appreciated.

  • .ResetBindings() can change the scrolling state of the listbox, which is why the code below saves and restores it explicitly. It does, however, automatically retain the selection status of the listbox (which the deleting-and-re-adding approach would have to manage manually too).

using namespace System.Windows.Forms
using namespace System.ComponentModel

Add-Type -AssemblyName System.Windows.Forms

$Form = [Form]@{ StartPosition = 'CenterScreen' }
$ListBox = [ListBox]@{}

# Define the items as a strongly typed array of item strings.
[string[]] $items = @('one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight')
# Wrap the array in a [BindingList[string]] instance
# and make it the listbox's data source.
$ListBox.DataSource = [BindingList[string]]::new($items)

$Form.Controls.Add($ListBox)
$ListBox.ContextMenuStrip = [ContextMenuStrip]@{}
$Context = $ListBox.ContextMenuStrip.Items.Add('ToUpper')
$ListBox.Add_MouseDown({ param($s, $e) 
    if ($e.Button -eq 'Right') { $Script:CurrentIndex = $ListBox.IndexFromPoint($e.Location) }
})
$Context.Add_Click({ param($s, $e)
    if ($null -ne $CurrentIndex) {
        # Modify the underlying array.
        $items[$CurrentIndex] = $items[$CurrentIndex].ToUpper()
        # To prevent unwanted scrolling during the refresh below, 
        # save and restore what item is currently shown at the top 
        # of the visible list portion.
        $prevTopIndex = $ListBox.TopIndex 
        # Notify the listbox via the BindingList[string] instance that
        # the items have changed.
        # !! The following *item-specific* call does NOT work.
        # !!   $ListBox.DataSource.ResetItem($CurrentIndex)
        $ListBox.DataSource.ResetBindings()
        $ListBox.TopIndex = $prevTopIndex # Restore the scrolling state.
    }
})
$Form.ShowDialog()

[1] A general syntax observation: The verbose alternative is to explicitly call the .Item method that implements the parameterized property that provides index-based access: $ListBox.Items.Item($CurrentIndex). This applies to any IList-implementing collection type, including arrays. However, there is generally no need for this, given that PowerShell surfaces such parameterized properties via direct indexing. E.g., given an array $a = 'foo', 'bar', $a.Item(1) is more naturally expressed as $a[1]

mklement0
  • 382,024
  • 64
  • 607
  • 775