Skip to content

Commit 7e58a3e

Browse files
committed
+semver:minor Added ContributorsListControl.Initialize method to allow the model to be set later when using the (existing) parameterless constructor in Designer.
Made ContributorsListControl more threadsafe. Possibly fixes crashes like SP-2353 or at least makes them less likely. Made the TestApp's ContributorsForm able to be edited in Designer and added code to test accessing various methods and properties on ContributorsListControl from a non-UI thread Also did some minor refactoring, comment/spelling cleanup, etc. [WIP?] See REVIEW comment at line 259 in ContributorsListControl.cs
1 parent 4282065 commit 7e58a3e

File tree

9 files changed

+290
-114
lines changed

9 files changed

+290
-114
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
2121
- [SIL.WritingSystems] Added public DownloadLanguageTags method for updating the cached langtags.json from the SLDR repository.
2222
- [SIL.Core] Add environment variable to disable `GlobalMutex` across processes. Helpful for snap packages in Linux.
2323
- [SIL.Core] Added string extension method SplitLines.
24+
- [SIL.Windows.Forms] Added ContributorsListControl.Initialize method to allow the model to be set later when using the (existing) parameterless constructor in Designer.
2425

2526
### Changed
2627

@@ -61,6 +62,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
6162

6263
- [SIL.Windows.Forms] Changed build date in SILAboutBox to be computed using the last write time instead of creation time.
6364
- [SIL.Windows.Forms] Made FadingMessageWindow implement all UI logic on the main UI thread in a thread-safe way. Fixes crashes like SP-2340.
65+
- [SIL.Windows.Forms] Made ContributorsListControl more threadsafe. Possibly fixes crashes like SP-2353 or at least makes them less likely.
6466

6567
## [15.0.0] - 2025-01-06
6668

SIL.Windows.Forms/ClearShare/ContributorsListControlViewModel.cs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,7 @@ public ContributorsListControlViewModel(IAutoCompleteValueProvider autoCompleteP
2626
}
2727

2828
/// ------------------------------------------------------------------------------------
29-
public IEnumerable<Role> OlacRoles
30-
{
31-
get { return _olacSystem.GetRoles(); }
32-
}
29+
public IEnumerable<Role> OlacRoles => _olacSystem.GetRoles();
3330

3431
/// ------------------------------------------------------------------------------------
3532
public GridSettings ContributorsGridSettings
@@ -53,8 +50,7 @@ internal void SaveContributionList(ContributionCollection list)
5350
{
5451
Contributions = (list ?? new ContributionCollection());
5552

56-
if (_saveAction != null)
57-
_saveAction();
53+
_saveAction?.Invoke();
5854
}
5955

6056
/// ------------------------------------------------------------------------------------

SIL.Windows.Forms/ClearShare/WinFormsUI/ContributorsListControl.cs

Lines changed: 115 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Collections.Generic;
33
using System.ComponentModel;
44
using System.ComponentModel.Design;
5+
using System.Diagnostics;
56
using System.Drawing;
67
using System.Linq;
78
using System.Media;
@@ -10,8 +11,10 @@
1011
using L10NSharp.UI;
1112
using SIL.Code;
1213
using SIL.Core.ClearShare;
14+
using SIL.Reporting;
1315
using SIL.Windows.Forms.Extensions;
1416
using SIL.Windows.Forms.Widgets.BetterGrid;
17+
using static System.String;
1518

1619
namespace SIL.Windows.Forms.ClearShare.WinFormsUI
1720
{
@@ -39,7 +42,7 @@ public delegate KeyValuePair<string, string> ValidatingContributorHandler(
3942
public event ColumnHeaderMouseClickHandler ColumnHeaderMouseClick;
4043

4144
private FadingMessageWindow _msgWindow;
42-
private readonly ContributorsListControlViewModel _model;
45+
private ContributorsListControlViewModel _model;
4346

4447
/// ------------------------------------------------------------------------------------
4548
public ContributorsListControl()
@@ -48,6 +51,13 @@ public ContributorsListControl()
4851
_grid.DataError += _grid_DataError;
4952
}
5053

54+
/// ------------------------------------------------------------------------------------
55+
public ContributorsListControl(ContributorsListControlViewModel model) : this()
56+
{
57+
Initialize(model);
58+
}
59+
60+
/// ------------------------------------------------------------------------------------
5161
private void _grid_DataError(object sender, DataGridViewDataErrorEventArgs e)
5262
{
5363
if (e.Exception != null &&
@@ -58,11 +68,25 @@ private void _grid_DataError(object sender, DataGridViewDataErrorEventArgs e)
5868
}
5969

6070
/// ------------------------------------------------------------------------------------
61-
public ContributorsListControl(ContributorsListControlViewModel model) : this()
71+
[PublicAPI]
72+
public void Initialize(ContributorsListControlViewModel model)
6273
{
74+
Debug.Assert(_model == null);
75+
6376
_model = model;
6477
_model.NewContributionListAvailable += HandleNewContributionListAvailable;
6578

79+
// Do not use SafeInvoke here because we want this to work even if called before the
80+
// handle is created.
81+
if (InvokeRequired)
82+
Invoke(new Action(InitializeGrid));
83+
else
84+
InitializeGrid();
85+
}
86+
87+
/// ------------------------------------------------------------------------------------
88+
private void InitializeGrid()
89+
{
6690
_grid.Font = SystemFonts.MenuFont;
6791

6892
DataGridViewColumn col = BetterGrid.CreateTextBoxColumn(kNameColName, "Name");
@@ -99,8 +123,12 @@ public ContributorsListControl(ContributorsListControlViewModel model) : this()
99123
_grid.ColumnHeaderMouseClick += _grid_ColumnHeaderMouseClick;
100124

101125
_model.ContributorsGridSettings?.InitializeGrid(_grid);
126+
127+
if (_model.Contributions.Any())
128+
HandleNewContributionListAvailable(_model, EventArgs.Empty);
102129
}
103130

131+
/// ------------------------------------------------------------------------------------
104132
// SP-874: Not able to open L10NSharp with Alt-Shift-click
105133
private void _grid_ColumnHeaderMouseClick(object sender, DataGridViewCellMouseEventArgs e)
106134
{
@@ -119,26 +147,37 @@ private void _grid_ColumnHeaderMouseClick(object sender, DataGridViewCellMouseEv
119147
GetService(typeof(IDesignerHost)) != null ||
120148
LicenseManager.UsageMode == LicenseUsageMode.Designtime;
121149

150+
/// ------------------------------------------------------------------------------------
122151
public void SetColumnAutoSizeMode(StandardColumns col, DataGridViewAutoSizeColumnMode autoSizeMode)
123152
{
124-
DataGridViewColumn column;
125-
switch (col)
153+
if (_model == null)
126154
{
127-
case StandardColumns.Name:
128-
column = Grid.Columns[kNameColName];
129-
break;
130-
case StandardColumns.Role:
131-
column = Grid.Columns[kRoleColName];
132-
break;
133-
case StandardColumns.Comments:
134-
column = Grid.Columns[kCommentsColName];
135-
break;
136-
case StandardColumns.Date:
137-
column = Grid.Columns[kDateColName];
138-
break;
139-
default:
140-
throw new ArgumentOutOfRangeException(nameof(col), col, null);
155+
if (IsDisposed)
156+
throw new ObjectDisposedException(Name ?? GetType().Name);
157+
throw new InvalidOperationException(
158+
$"{nameof(Initialize)} must be called to set the model first.");
141159
}
160+
161+
// Do not use SafeInvoke here because we want this to work even if called before the
162+
// handle is created.
163+
if (Grid.InvokeRequired)
164+
Grid.Invoke(new Action(() => SetColumnAutoSizeMode_Internal(col, autoSizeMode)));
165+
else
166+
SetColumnAutoSizeMode_Internal(col, autoSizeMode);
167+
}
168+
169+
/// ------------------------------------------------------------------------------------
170+
private void SetColumnAutoSizeMode_Internal(StandardColumns col, DataGridViewAutoSizeColumnMode autoSizeMode)
171+
{
172+
var column = col switch
173+
{
174+
StandardColumns.Name => Grid.Columns[kNameColName],
175+
StandardColumns.Role => Grid.Columns[kRoleColName],
176+
StandardColumns.Comments => Grid.Columns[kCommentsColName],
177+
StandardColumns.Date => Grid.Columns[kDateColName],
178+
_ => throw new ArgumentOutOfRangeException(nameof(col), col, null)
179+
};
180+
142181
if (column != null)
143182
column.AutoSizeMode = autoSizeMode;
144183
}
@@ -149,7 +188,7 @@ public bool InEditMode
149188
{
150189
get
151190
{
152-
if (_grid.InvokeRequired)
191+
if (InvokeRequired)
153192
return (bool)_grid.Invoke(new Func<bool>(() => _grid.IsCurrentRowDirty));
154193
return _grid.IsCurrentRowDirty;
155194
}
@@ -175,23 +214,50 @@ protected override void OnHandleDestroyed(EventArgs e)
175214
base.OnHandleDestroyed(e);
176215
}
177216

178-
/// ------------------------------------------------------------------------------------
179217
private void HandleNewContributionListAvailable(object sender, EventArgs e)
180218
{
181-
Guard.AgainstNull(_model.Contributions, "Contributions");
219+
if (InvokeRequired)
220+
{
221+
// Since BeginInvoke ensures the call is executed in the UI message loop (at a
222+
// later time), it might avoid conflicts with UI state updates still in progress.
223+
_grid.BeginInvoke(new Action(() => HandleNewContributionListAvailable(sender, e)));
224+
return;
225+
}
182226

183-
_grid.RowValidated -= HandleGridRowValidated;
184-
_grid.RowsRemoved -= HandleGridRowsRemoved;
185-
_grid.Rows.Clear();
227+
Guard.AgainstNull(_model.Contributions, nameof(_model.Contributions));
186228

187-
foreach (var contribution in _model.Contributions)
188-
_grid.Rows.Add(contribution.ContributorName, contribution.Role.Name, contribution.Date, contribution.Comments);
229+
try
230+
{
231+
// Ensure any pending edits are committed or canceled before modifying rows
232+
if (_grid.IsCurrentCellInEditMode)
233+
_grid.EndEdit();
234+
if (_grid.CurrentCell != null && _grid.CurrentRow?.IsNewRow == false)
235+
_grid.CommitEdit(DataGridViewDataErrorContexts.Commit);
189236

190-
_grid.CurrentCell = _grid[0, 0];
191-
_grid.IsDirty = false;
237+
_grid.RowValidated -= HandleGridRowValidated;
238+
_grid.RowsRemoved -= HandleGridRowsRemoved;
239+
240+
_grid.SuspendLayout(); // Improve performance when modifying multiple rows
241+
_grid.Rows.Clear();
192242

193-
_grid.RowValidated += HandleGridRowValidated;
194-
_grid.RowsRemoved += HandleGridRowsRemoved;
243+
foreach (var contribution in _model.Contributions)
244+
_grid.Rows.Add(contribution.ContributorName, contribution.Role.Name, contribution.Date, contribution.Comments);
245+
246+
if (_grid.Rows.Count > 0)
247+
_grid.CurrentCell = _grid[0, 0];
248+
249+
_grid.IsDirty = false;
250+
251+
_grid.ResumeLayout(); // Re-enable layout updates
252+
253+
_grid.RowValidated += HandleGridRowValidated;
254+
_grid.RowsRemoved += HandleGridRowsRemoved;
255+
}
256+
catch (InvalidOperationException ex)
257+
{
258+
Logger.WriteError("Error updating contribution list", ex);
259+
// REVIEW: Then what? Retry every second until this succeeds???
260+
}
195261
}
196262

197263
/// ------------------------------------------------------------------------------------
@@ -277,7 +343,7 @@ private void HandleGridRowValidating(object sender, DataGridViewCellCancelEventA
277343

278344
var kvp = ValidatingContributor(this, contribution, e);
279345

280-
if (!string.IsNullOrEmpty(kvp.Key))
346+
if (!IsNullOrEmpty(kvp.Key))
281347
{
282348
_msgWindow ??= new FadingMessageWindow();
283349

@@ -390,12 +456,12 @@ private void HandleGridCellEndEdit(object sender, DataGridViewCellEventArgs e)
390456
// is the current text an exact match for the autocomplete list?
391457
var list = txtBox.AutoCompleteCustomSource.Cast<object>().ToList();
392458
var found = list.FirstOrDefault(item =>
393-
String.Equals(item.ToString(), txtBox.Text, StringComparison.CurrentCulture));
459+
string.Equals(item.ToString(), txtBox.Text, StringComparison.CurrentCulture));
394460

395461
if (found == null)
396462
{
397463
// is the current text a match except for case for the autocomplete list?
398-
found = list.FirstOrDefault(item => String.Equals(item.ToString(),
464+
found = list.FirstOrDefault(item => string.Equals(item.ToString(),
399465
txtBox.Text, StringComparison.CurrentCultureIgnoreCase));
400466
if (found != null)
401467
{
@@ -442,7 +508,19 @@ private void DeleteRow(int rowIndex)
442508
/// <remarks>SP-874: Localize column headers</remarks>
443509
public void SetColumnHeaderText(int columnIndex, string headerText)
444510
{
445-
_grid.Columns[columnIndex].HeaderText = headerText;
511+
if (_model == null)
512+
{
513+
if (IsDisposed)
514+
throw new ObjectDisposedException(Name ?? GetType().Name);
515+
516+
throw new InvalidOperationException(
517+
$"{nameof(Initialize)} must be called to set the model first.");
518+
}
519+
520+
if (InvokeRequired)
521+
BeginInvoke(new Action(() => { _grid.Columns[columnIndex].HeaderText = headerText; }));
522+
else
523+
_grid.Columns[columnIndex].HeaderText = headerText;
446524
}
447525

448526
/// <remarks>SP-874: Localize column headers</remarks>
@@ -453,7 +531,9 @@ public void SetLocalizationExtender(L10NSharpExtender extender)
453531
extender.SetLocalizingId(_grid, "ContributorsEditorGrid");
454532
}
455533

456-
/// <remarks>We need to be able to adjust the visual properties to match the hosting program</remarks>
534+
/// <remarks>We need to be able to adjust the visual properties to match the hosting program.
535+
/// If used in code that could run on a thread other than the main UI thread, caller is
536+
/// responsible for invoking on UI thread if needed.</remarks>
457537
public BetterGrid Grid => _grid;
458538
}
459539
}

SIL.Windows.Forms/ClearShare/WinFormsUI/ContributorsListControl.designer.cs

Lines changed: 10 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

SIL.Windows.Forms/Extensions/ControlExtensions.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,15 +168,15 @@ public static IAsyncResult SafeInvoke(this Control control, Action action, strin
168168
// between the time we invoke and the time the action is executed. All we really need from SafeInvoke is that little
169169
// bit of code at the start that checks for IsDisposed, but re-using SafeInvoke for the delegate is probably better
170170
// than repeating that bit of code. Rechecking InvokeRequired should be lightning fast.
171-
var delgate = (Action)delegate
171+
var actionToInvoke = (Action)delegate
172172
{
173173
treatInvalidOperationExceptionAsObjectDisposedException = false;
174174
control.SafeInvoke(action, nameForErrorReporting, errorHandling, forceSynchronous);
175175
};
176176
if (forceSynchronous)
177-
control.Invoke(delgate);
177+
control.Invoke(actionToInvoke);
178178
else
179-
return control.BeginInvoke(delgate);
179+
return control.BeginInvoke(actionToInvoke);
180180
}
181181
catch (InvalidOperationException e)
182182
{

0 commit comments

Comments
 (0)