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
57 changes: 45 additions & 12 deletions src/Sic/MainWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public MainWindow() {
SetupEventHandlers();
PopulateFormatComboBox();
UpdateMenuState();
UpdatePlaceholderState();

try {
_updateService = new UpdateService();
Expand Down Expand Up @@ -110,8 +111,14 @@ private void UpdateMenuState() {
convertSelectedMenuItem.Enabled = hasSelection;
convertAllMenuItem.Enabled = hasItems;
createMultiSizeIcoMenuItem.Enabled = hasSelection;
}

if (hasItems) {
// Placeholder management is split out of UpdateMenuState because Items.Insert/Remove
// must NOT run from inside ListView event handlers (SelectedIndexChanged fires from
// inside ListView.WndProc during removal — mutating Items there causes internal
// NullReferenceExceptions). Call this only from user-initiated add/remove flows.
private void UpdatePlaceholderState() {
if (_imageItems.Count > 0) {
HidePlaceholder();
} else {
ShowPlaceholder();
Expand All @@ -125,6 +132,11 @@ private void ShowPlaceholder() {
ForeColor = SystemColors.GrayText
};
imageListView.Items.Insert(0, _placeholderItem);
// Select + focus so a screen reader announces the empty-list text.
// The convert/remove handlers guard against _imageItems being empty,
// so nothing acts on this fake selection.
_placeholderItem.Selected = true;
_placeholderItem.Focused = true;
}

private void HidePlaceholder() {
Expand Down Expand Up @@ -180,7 +192,7 @@ private async void AddByLinkMenuItem_Click(object? sender, EventArgs e) {
}

private void RemoveMenuItem_Click(object? sender, EventArgs e) {
if (imageListView.SelectedIndices.Count == 0)
if (_imageItems.Count == 0 || imageListView.SelectedIndices.Count == 0)
return;

var index = imageListView.SelectedIndices[0];
Expand All @@ -199,15 +211,19 @@ private void RemoveMenuItem_Click(object? sender, EventArgs e) {

statusLabel.Text = _n("1 image in queue", "{0} images in queue", _imageItems.Count, _imageItems.Count);
UpdateMenuState();
UpdatePlaceholderState();
}

private void RemoveAllMenuItem_Click(object? sender, EventArgs e) {
imageListView.SelectedIndices.Clear();
imageListView.FocusedItem = null;
_imageItems.Clear();
imageListView.Items.Clear();
previewPictureBox.Image?.Dispose();
previewPictureBox.Image = null;
statusLabel.Text = _("Ready");
UpdateMenuState();
UpdatePlaceholderState();
}

private void SettingsMenuItem_Click(object? sender, EventArgs e) {
Expand Down Expand Up @@ -416,9 +432,15 @@ await Task.Run(() => {
progressDialog?.Dispose();
}

// Remove successfully converted items from queue (in reverse order to preserve indices)
// Remove successfully converted items from queue (in reverse order to preserve indices).
// Clearing selection/focus before removal avoids a ListView.WndProc NRE that can fire
// when the native control dispatches pending accessibility/dispinfo messages referencing
// an item that's just been removed.
imageListView.BeginUpdate();
try {
imageListView.SelectedIndices.Clear();
imageListView.FocusedItem = null;

foreach (var index in convertedIndices.OrderByDescending(i => i)) {
_imageItems.RemoveAt(index);
imageListView.Items.RemoveAt(index);
Expand Down Expand Up @@ -446,8 +468,11 @@ await Task.Run(() => {
summary += " " + _("Cancelled.");

statusLabel.Text = summary;
MessageBox.Show(summary, _("Conversion Complete"), MessageBoxButtons.OK, MessageBoxIcon.Information);
// Must run before MessageBox so the placeholder row is inserted before the
// MessageBox pump dispatches any queued ListView notifications.
UpdateMenuState();
UpdatePlaceholderState();
MessageBox.Show(summary, _("Conversion Complete"), MessageBoxButtons.OK, MessageBoxIcon.Information);
}

private async void ConvertButton_Click(object? sender, EventArgs e) {
Expand All @@ -462,15 +487,15 @@ private async void ConvertButton_Click(object? sender, EventArgs e) {
}

private async void ConvertSelectedMenuItem_Click(object? sender, EventArgs e) {
if (imageListView.SelectedIndices.Count == 0)
if (_imageItems.Count == 0 || imageListView.SelectedIndices.Count == 0)
return;

var selectedIndices = imageListView.SelectedIndices.Cast<int>().ToList();
await ConvertItemsAsync(selectedIndices);
}

private async void CreateMultiSizeIcoMenuItem_Click(object? sender, EventArgs e) {
if (imageListView.SelectedIndices.Count == 0)
if (_imageItems.Count == 0 || imageListView.SelectedIndices.Count == 0)
return;

using var presetDialog = new IcoPresetDialog();
Expand Down Expand Up @@ -517,13 +542,18 @@ await Task.Run(() => {
ImageConverter.CreateMultiSizeIco(item, outputPath, sizes);
}).WaitAsync(progressDialog.CancellationToken);

imageListView.SelectedIndices.Clear();
imageListView.FocusedItem = null;
_imageItems.RemoveAt(index);
imageListView.Items.RemoveAt(index);
previewPictureBox.Image?.Dispose();
previewPictureBox.Image = null;

statusLabel.Text = _("Multi-size ICO created: {0}", Path.GetFileName(outputPath));
UpdateMenuState();
UpdatePlaceholderState();
MessageBox.Show(_("Multi-size ICO created successfully:\n{0}", outputPath), _("ICO Created"), MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
} catch (OperationCanceledException) {
imageListView.Items[index].SubItems[4].Text = "";
statusLabel.Text = _("Ready");
Expand All @@ -538,6 +568,7 @@ await Task.Run(() => {
}

UpdateMenuState();
UpdatePlaceholderState();
}

private void DonateMenuItem_Click(object? sender, EventArgs e) {
Expand Down Expand Up @@ -718,14 +749,12 @@ private void UpdatePreview() {
}

private void ImageListView_SelectedIndexChanged(object? sender, EventArgs e) {
if (_placeholderItem != null && _placeholderItem.Selected) {
_placeholderItem.Selected = false;
return;
}

UpdateMenuState();

if (imageListView.SelectedIndices.Count == 0) {
// Treat placeholder selection (or any selection when _imageItems is empty)
// as "no real selection" — clear preview state but leave the placeholder
// focused so screen readers can announce it.
if (imageListView.SelectedIndices.Count == 0 || _imageItems.Count == 0) {
_selectedItem = null;
previewPictureBox.Image?.Dispose();
previewPictureBox.Image = null;
Expand Down Expand Up @@ -810,6 +839,7 @@ private async void HandlePaste() {
var item = ImageConverter.LoadFromBytes(data, $"clipboard_image{suffix}.png");
AddImageItem(item);
UpdateMenuState();
UpdatePlaceholderState();
statusLabel.Text = _("Added image from clipboard");
} catch (Exception ex) {
Log.Error("Failed to load clipboard image: {Error}", ex.Message);
Expand Down Expand Up @@ -854,6 +884,7 @@ private async Task PasteFromUrlAsync(string url) {

AddImageItem(item);
UpdateMenuState();
UpdatePlaceholderState();
statusLabel.Text = _("Added {0} from URL", item.FileName);
} catch (OperationCanceledException) {
statusLabel.Text = _("Ready");
Expand All @@ -878,6 +909,7 @@ private async Task AddFilesAsync(string[] paths, string? basePath = null) {
if (paths.Length == 1) {
AddImageFromFile(paths[0], basePath);
UpdateMenuState();
UpdatePlaceholderState();
return;
}

Expand Down Expand Up @@ -1001,6 +1033,7 @@ private async Task AddFilesAsync(string[] paths, string? basePath = null) {

statusLabel.Text = _n("1 image in queue", "{0} images in queue", _imageItems.Count, _imageItems.Count);
UpdateMenuState();
UpdatePlaceholderState();
imageListView.Focus();

if (result.Errors.Count > 0 || result.SkippedPlaceholders > 0) {
Expand Down
34 changes: 34 additions & 0 deletions src/Sic/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,25 @@ static int Main(string[] args) {
e.SetObserved();
};

// Swallow stray NullReferenceExceptions raised inside WinForms internals
// (notably ListView.WndProc / ListView.Unhook races with accessibility,
// dispinfo and item-removal notifications). For other UI-thread exceptions,
// log and keep the app alive with a non-fatal dialog rather than killing
// the process.
Application.ThreadException += (s, e) => {
if (IsWinFormsInternalNullReference(e.Exception)) {
Log.Warning(e.Exception, "Suppressed WinForms internal NullReferenceException in {Method}",
e.Exception.TargetSite?.Name ?? "<unknown>");
return;
}

Log.Error(e.Exception, "Unhandled UI thread exception");
MessageBox.Show(
_("An unexpected error occurred:\n{0}\n\nThe application will continue running.", e.Exception.Message),
_("Error"),
MessageBoxButtons.OK, MessageBoxIcon.Error);
};

try {
if (args.Length > 0) {
return RunCli(args);
Expand Down Expand Up @@ -63,6 +82,21 @@ static int Main(string[] args) {
}
}

private static bool IsWinFormsInternalNullReference(Exception ex) {
if (ex is not NullReferenceException)
return false;

var declaringNamespace = ex.TargetSite?.DeclaringType?.Namespace;
if (declaringNamespace?.StartsWith("System.Windows.Forms", StringComparison.Ordinal) == true)
return true;

// Fallback: TargetSite can be null for some optimized frames. If every
// frame in the stack belongs to System.Windows.Forms, it's still a
// framework-internal race and safe to suppress.
var stack = ex.StackTrace;
return stack != null && stack.Contains("System.Windows.Forms.", StringComparison.Ordinal);
}

private static void EnsureOutputFolderExists(bool showGui) {
var outputFolder = Config.General.OutputFolder;

Expand Down
4 changes: 2 additions & 2 deletions src/Sic/Sic.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
Expand Down Expand Up @@ -37,7 +37,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="GetText.NET.WindowsForms" Version="10.0.1" />
<PackageReference Include="GetText.NET.WindowsForms" Version="10.0.1" NoWarn="NU1701" />
<PackageReference Include="Magick.NET-Q16-x64" Version="14.12.0" />
<PackageReference Include="GitVersion.MsBuild" Version="6.7.0">
<PrivateAssets>all</PrivateAssets>
Expand Down
Loading