diff --git a/src/EVEMon.Common/Collections/Global/GlobalCharacterCollection.cs b/src/EVEMon.Common/Collections/Global/GlobalCharacterCollection.cs index faaa9d9a2..247aeeb48 100644 --- a/src/EVEMon.Common/Collections/Global/GlobalCharacterCollection.cs +++ b/src/EVEMon.Common/Collections/Global/GlobalCharacterCollection.cs @@ -142,7 +142,7 @@ internal void Import(IEnumerable serial) character.Dispose(); } - // Import the characters, their identies, etc + // Import the characters, their identities, etc Items.Clear(); foreach (SerializableSettingsCharacter serialCharacter in serial) { diff --git a/src/EVEMon.Common/Collections/Global/GlobalMonitoredCharacterCollection.cs b/src/EVEMon.Common/Collections/Global/GlobalMonitoredCharacterCollection.cs index 21ef64987..6e0b7190e 100644 --- a/src/EVEMon.Common/Collections/Global/GlobalMonitoredCharacterCollection.cs +++ b/src/EVEMon.Common/Collections/Global/GlobalMonitoredCharacterCollection.cs @@ -96,9 +96,11 @@ internal void Import(ICollection monitoredCharacters Items.Add(character); character.Monitored = true; character.UISettings = characterSettings.Settings; - - EveMonClient.OnMonitoredCharactersChanged(); } + + // Notify once after all characters have been imported, instead of + // per-character which caused N redundant LayoutTabPages + Settings.Save calls + EveMonClient.OnMonitoredCharactersChanged(); } /// diff --git a/src/EVEMon.Common/Collections/Global/GlobalNotificationCollection.cs b/src/EVEMon.Common/Collections/Global/GlobalNotificationCollection.cs index c8fcfef90..8c6c6d245 100644 --- a/src/EVEMon.Common/Collections/Global/GlobalNotificationCollection.cs +++ b/src/EVEMon.Common/Collections/Global/GlobalNotificationCollection.cs @@ -62,8 +62,10 @@ public void Notify(NotificationEventArgs notification) case NotificationBehaviour.Merge: // Merge the notifications with the same key + // Snapshot Items to prevent IndexOutOfRangeException if the notification + // chain (via ThreadSafeInvoke) re-enters and modifies Items concurrently long key = notification.InvalidationKey; - foreach (NotificationEventArgs other in Items.Where(x => x.InvalidationKey == key)) + foreach (NotificationEventArgs other in Items.ToArray().Where(x => x.InvalidationKey == key)) { notification.Append(other); } diff --git a/src/EVEMon.Common/Extensions/EventHandlerExtensions.cs b/src/EVEMon.Common/Extensions/EventHandlerExtensions.cs index 57cc7836c..a3f881137 100644 --- a/src/EVEMon.Common/Extensions/EventHandlerExtensions.cs +++ b/src/EVEMon.Common/Extensions/EventHandlerExtensions.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel; using System.Linq; +using System.Windows.Forms; namespace EVEMon.Common.Extensions { @@ -32,6 +33,11 @@ public static void ThreadSafeInvoke(this EventHandler eventHandler, object sende // Check if our target requires an Invoke if (sync != null && sync.InvokeRequired) { + // Skip if the target control is disposed or has no handle; + // invoking on such a control causes unpredictable exceptions. + if (sync is Control ctrl && (ctrl.IsDisposed || !ctrl.IsHandleCreated)) + continue; + // Yes it does, so invoke the handler using the target's BeginInvoke method, but wait for it to finish // This is preferable to using Invoke so that if an exception is thrown its presented // in the context of the handler, not the current thread @@ -43,7 +49,13 @@ public static void ThreadSafeInvoke(this EventHandler eventHandler, object sende } catch (ObjectDisposedException) { - // Ignore, already cleaned up -- code was likely changed to use `using`. + // Control was disposed between BeginInvoke and EndInvoke + } + catch (IndexOutOfRangeException) when (sync is Control c && (c.IsDisposed || !c.IsHandleCreated)) + { + // Control.EndInvoke throws IndexOutOfRangeException when the + // control's internal async result list is cleared during disposal + // or handle recreation (e.g. resize triggering layout changes). } continue; @@ -81,11 +93,31 @@ public static void ThreadSafeInvoke(this EventHandler eventHandler, object // Check if our target requires an Invoke if (sync != null && sync.InvokeRequired) { + // Skip if the target control is disposed or has no handle; + // invoking on such a control causes unpredictable exceptions. + if (sync is Control ctrl && (ctrl.IsDisposed || !ctrl.IsHandleCreated)) + continue; + // Yes it does, so invoke the handler using the target's BeginInvoke method, but wait for it to finish // This is preferable to using Invoke so that if an exception is thrown its presented // in the context of the handler, not the current thread - IAsyncResult result = sync.BeginInvoke(handler, new[] { sender, e }); - sync.EndInvoke(result); + IAsyncResult result = sync.BeginInvoke(handler, new[] { sender, (object)e }); + + try + { + sync.EndInvoke(result); + } + catch (ObjectDisposedException) + { + // Control was disposed between BeginInvoke and EndInvoke + } + catch (IndexOutOfRangeException) when (sync is Control c && (c.IsDisposed || !c.IsHandleCreated)) + { + // Control.EndInvoke throws IndexOutOfRangeException when the + // control's internal async result list is cleared during disposal + // or handle recreation (e.g. resize triggering layout changes). + } + continue; } diff --git a/src/EVEMon.Common/Helpers/FileHelper.cs b/src/EVEMon.Common/Helpers/FileHelper.cs index caa7f2e15..455b5bd7b 100644 --- a/src/EVEMon.Common/Helpers/FileHelper.cs +++ b/src/EVEMon.Common/Helpers/FileHelper.cs @@ -166,9 +166,23 @@ public static void CopyOrWarnTheUser(string srcFileName, string destFileName) /// private static void CopyFile(string srcFileName, string destFileName) { - using (Stream sourceStream = Util.GetFileStream(srcFileName, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (Stream destStream = Util.GetFileStream(destFileName, FileMode.Create, FileAccess.Write)) - sourceStream.CopyTo(destStream); + // Use a retry loop to handle transient IOException from concurrent file access + const int maxRetries = 3; + for (int attempt = 0; attempt < maxRetries; attempt++) + { + try + { + using (Stream sourceStream = Util.GetFileStream(srcFileName, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (Stream destStream = Util.GetFileStream(destFileName, FileMode.Create, FileAccess.Write)) + sourceStream.CopyTo(destStream); + return; + } + catch (IOException) when (attempt < maxRetries - 1) + { + // Brief delay before retry to let the other handle close + System.Threading.Thread.Sleep(100 * (attempt + 1)); + } + } } /// diff --git a/src/EVEMon.Common/Models/CCPCharacter.cs b/src/EVEMon.Common/Models/CCPCharacter.cs index 9a011169d..2840258fd 100644 --- a/src/EVEMon.Common/Models/CCPCharacter.cs +++ b/src/EVEMon.Common/Models/CCPCharacter.cs @@ -98,7 +98,8 @@ private CCPCharacter(CharacterIdentity identity, Guid guid) EveMonClient.CharacterPlaneteryPinsCompleted += EveMonClient_CharacterPlaneteryPinsCompleted; EveMonClient.ESIKeyInfoUpdated += EveMonClient_ESIKeyInfoUpdated; EveMonClient.EveIDToNameUpdated += EveMonClient_EveIDToNameUpdated; - EveMonClient.TimerTick += EveMonClient_TimerTick; + // Note: TimerTick subscription is deferred until data querying starts + // to reduce UI thread overhead during startup } /// @@ -473,8 +474,8 @@ private void Import(SerializableCCPCharacter serial) // EVE notifications IDs EVENotifications.Import(serial.EveNotificationsIDs); - // Kill Logs - KillLog.ImportFromCacheFile(); + // Kill Logs are loaded lazily on first access to avoid + // synchronous file I/O during startup // Fire the global event EveMonClient.OnCharacterUpdated(this); @@ -758,11 +759,14 @@ private void EveMonClient_ESIKeyInfoUpdated(object sender, EventArgs e) if (!Identity.ESIKeys.Any()) return; + bool needsTimerTick = false; + if (m_characterDataQuerying == null && Identity.ESIKeys.Any()) { m_characterDataQuerying = new CharacterDataQuerying(this); ResetLastAPIUpdates(m_lastAPIUpdates.Where(lastUpdate => Enum.IsDefined( typeof(ESIAPICharacterMethods), lastUpdate.Method))); + needsTimerTick = true; } if (m_corporationDataQuerying == null && Identity.ESIKeys.Any()) @@ -770,7 +774,12 @@ private void EveMonClient_ESIKeyInfoUpdated(object sender, EventArgs e) m_corporationDataQuerying = new CorporationDataQuerying(this); ResetLastAPIUpdates(m_lastAPIUpdates.Where(lastUpdate => Enum.IsDefined( typeof(ESIAPICorporationMethods), lastUpdate.Method))); + needsTimerTick = true; } + + // Subscribe to TimerTick only once data querying is active + if (needsTimerTick) + EveMonClient.TimerTick += EveMonClient_TimerTick; } /// @@ -895,11 +904,17 @@ private void EveMonClient_CharacterIndustryJobsCompleted(object sender, Industry if (!CorporationIndustryJobs.Any(job => job.ActiveJobState == ActiveJobState.Ready && !job.NotificationSend)) { - EveMonClient.Notifications.NotifyCharacterIndustryJobCompletion(this, - m_jobsCompletedForCharacter); + // Snapshot the list before notifying — the notification constructor + // iterates the enumerable, and Clear() below (or another timer tick + // calling AddRange) would modify the list mid-iteration, causing + // IndexOutOfRangeException. + var completedSnapshot = m_jobsCompletedForCharacter.ToList(); - // Now that we have send the notification clear the list + // Now that we have a snapshot, clear the list before notifying m_jobsCompletedForCharacter.Clear(); + + EveMonClient.Notifications.NotifyCharacterIndustryJobCompletion(this, + completedSnapshot); } } } diff --git a/src/EVEMon.Common/Models/Collections/IndustryJobCollection.cs b/src/EVEMon.Common/Models/Collections/IndustryJobCollection.cs index 320b96aed..cd9ee992f 100644 --- a/src/EVEMon.Common/Models/Collections/IndustryJobCollection.cs +++ b/src/EVEMon.Common/Models/Collections/IndustryJobCollection.cs @@ -128,10 +128,14 @@ private void UpdateOnTimerTick() bool isCorporateMonitor = true; if (Items.Count > 0) { + // Snapshot the items list to avoid IndexOutOfRangeException if the + // notification chain causes Items to be modified during iteration + var snapshot = Items.ToList(); + // Add the not notified "Ready" jobs to the completed list var jobsCompleted = new LinkedList(); var characterJobs = new LinkedList(); - foreach (IndustryJob job in Items) + foreach (IndustryJob job in snapshot) { if (job.IsActive && job.TTC.Length == 0 && !job.NotificationSend) { diff --git a/src/EVEMon.Common/Models/Collections/KillLogCollection.cs b/src/EVEMon.Common/Models/Collections/KillLogCollection.cs index b9aa2cc1f..17841372c 100644 --- a/src/EVEMon.Common/Models/Collections/KillLogCollection.cs +++ b/src/EVEMon.Common/Models/Collections/KillLogCollection.cs @@ -1,3 +1,4 @@ +using System.Collections; using System.Collections.Generic; using EVEMon.Common.Collections; using EVEMon.Common.Enumerations.CCPAPI; @@ -9,11 +10,12 @@ namespace EVEMon.Common.Models.Collections { - public sealed class KillLogCollection : ReadonlyCollection + public sealed class KillLogCollection : ReadonlyCollection, IEnumerable, IEnumerable { private readonly CCPCharacter m_ccpCharacter; private int m_killMailCounter; private readonly List m_pendingItems; + private bool m_cacheLoaded; #region Constructor @@ -26,6 +28,43 @@ internal KillLogCollection(CCPCharacter ccpCharacter) m_ccpCharacter = ccpCharacter; m_killMailCounter = 0; m_pendingItems = new List(32); + m_cacheLoaded = false; + } + + #endregion + + + #region Lazy Cache Loading + + /// + /// Ensures the kill log cache file has been loaded. Called lazily on first + /// enumeration to avoid synchronous file I/O during character construction. + /// + private void EnsureCacheLoaded() + { + if (m_cacheLoaded) + return; + + m_cacheLoaded = true; + ImportFromCacheFile(); + } + + /// + /// Gets the enumerator, ensuring the cache is loaded first. + /// + IEnumerator IEnumerable.GetEnumerator() + { + EnsureCacheLoaded(); + return Items.GetEnumerator(); + } + + /// + /// Gets the enumerator, ensuring the cache is loaded first. + /// + IEnumerator IEnumerable.GetEnumerator() + { + EnsureCacheLoaded(); + return Items.GetEnumerator(); } #endregion diff --git a/src/EVEMon.Common/Service/EveIDToName.cs b/src/EVEMon.Common/Service/EveIDToName.cs index 9439bc721..9a6313b95 100644 --- a/src/EVEMon.Common/Service/EveIDToName.cs +++ b/src/EVEMon.Common/Service/EveIDToName.cs @@ -3,6 +3,7 @@ using EVEMon.Common.Data; using EVEMon.Common.Enumerations.CCPAPI; using EVEMon.Common.Extensions; +using EVEMon.Common.Helpers; using EVEMon.Common.Models; using EVEMon.Common.Serialization; using EVEMon.Common.Serialization.Esi; @@ -10,6 +11,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using StringIDInfo = EVEMon.Common.Service.IDInformation; @@ -29,7 +31,14 @@ public static class EveIDToName private static readonly GenericIDToNameProvider s_lookup = new GenericIDToNameProvider(s_cacheList); + /// + /// Serializes all file I/O (load and save) to prevent concurrent access to + /// EveIDToName.xml which causes IOException due to exclusive file handles. + /// + private static readonly SemaphoreSlim s_fileLock = new SemaphoreSlim(1, 1); + private static bool s_savePending; + private static bool s_saveRunning; private static DateTime s_lastSaveTime; /// @@ -49,7 +58,14 @@ static EveIDToName() /// The instance containing the event data. private static async void EveMonClient_TimerTick(object sender, EventArgs e) { - await UpdateOnOneSecondTickAsync(); + try + { + await UpdateOnOneSecondTickAsync(); + } + catch (Exception ex) + { + ExceptionHandler.LogException(ex, true); + } } /// @@ -87,14 +103,26 @@ public static void InitializeFromFile() // Quit if the client has been shut down if (EveMonClient.Closed || s_cacheList.Any()) return; - // Deserialize the file - var cache = LocalXmlCache.Load(Filename, true); - if (cache != null) - // Add the data to the cache - Import(cache.Entities.Select(entity => new SerializableCharacterNameListItem { - ID = entity.ID, - Name = entity.Name - })); + + // Acquire the file lock synchronously to prevent reading while a save is in progress + s_fileLock.Wait(); + try + { + // Deserialize the file + var cache = LocalXmlCache.Load(Filename, true); + if (cache != null) + // Add the data to the cache + Import(cache.Entities.Select(entity => new SerializableCharacterNameListItem + { + ID = entity.ID, + Name = entity.Name + })); + } + finally + { + s_fileLock.Release(); + } + // For blank corporations and alliances s_lookup.Prefill(0L, "(None)"); } @@ -114,8 +142,8 @@ private static void Import(IEnumerable entiti /// private static Task UpdateOnOneSecondTickAsync() { - // Is a save requested and is the last save older than 10s ? - if (s_savePending && DateTime.UtcNow > s_lastSaveTime.AddSeconds(10)) + // Is a save requested, not already running, and is the last save older than 10s? + if (s_savePending && !s_saveRunning && DateTime.UtcNow > s_lastSaveTime.AddSeconds(10)) return SaveImmediateAsync(); return Task.CompletedTask; @@ -126,12 +154,32 @@ private static Task UpdateOnOneSecondTickAsync() /// public static async Task SaveImmediateAsync() { - // Save in file - await LocalXmlCache.SaveAsync(Filename, Util.SerializeToXmlDocument(Export())); + // Prevent re-entrant saves (timer tick + shutdown can overlap) + if (s_saveRunning) + return; + + s_saveRunning = true; + try + { + await s_fileLock.WaitAsync().ConfigureAwait(false); + try + { + // Save in file + await LocalXmlCache.SaveAsync(Filename, Util.SerializeToXmlDocument(Export())); - // Reset savePending flag - s_lastSaveTime = DateTime.UtcNow; - s_savePending = false; + // Reset savePending flag + s_lastSaveTime = DateTime.UtcNow; + s_savePending = false; + } + finally + { + s_fileLock.Release(); + } + } + finally + { + s_saveRunning = false; + } } /// diff --git a/src/EVEMon/CharacterMonitoring/CharacterMonitorFooter.cs b/src/EVEMon/CharacterMonitoring/CharacterMonitorFooter.cs index 435329756..981d65ed9 100644 --- a/src/EVEMon/CharacterMonitoring/CharacterMonitorFooter.cs +++ b/src/EVEMon/CharacterMonitoring/CharacterMonitorFooter.cs @@ -61,6 +61,10 @@ protected override void OnLoad(EventArgs e) EveMonClient.SchedulerChanged += EveMonClient_SchedulerChanged; EveMonClient.CharacterSkillQueueUpdated += EveMonClient_CharacterSkillQueueUpdated; Disposed += OnDisposed; + + // Populate controls immediately for lazy monitor creation + UpdateFrequentControls(); + UpdateInfrequentControls(); } /// diff --git a/src/EVEMon/CharacterMonitoring/CharacterMonitorHeader.cs b/src/EVEMon/CharacterMonitoring/CharacterMonitorHeader.cs index 1f2e88570..c659a0795 100644 --- a/src/EVEMon/CharacterMonitoring/CharacterMonitorHeader.cs +++ b/src/EVEMon/CharacterMonitoring/CharacterMonitorHeader.cs @@ -79,6 +79,12 @@ protected override void OnLoad(EventArgs e) EveMonClient.SettingsChanged += EveMonClient_SettingsChanged; EveMonClient.TimerTick += EveMonClient_TimerTick; Disposed += OnDisposed; + + // Populate controls immediately — when the monitor is created lazily, + // the initial CharacterUpdated/CharacterInfoUpdated events have already + // fired and won't repeat until the next API refresh + UpdateFrequentControls(); + UpdateInfrequentControls(); } /// diff --git a/src/EVEMon/Controls/Overview.cs b/src/EVEMon/Controls/Overview.cs index 7c820fe9f..7a29bab1e 100644 --- a/src/EVEMon/Controls/Overview.cs +++ b/src/EVEMon/Controls/Overview.cs @@ -24,6 +24,7 @@ public partial class Overview : UserControl private bool m_safeForWork; private PortraitSizes m_portraitSize; private bool m_showPortrait; + private bool m_pendingUpdate; #region Constructor @@ -64,6 +65,7 @@ protected override void OnLoad(EventArgs e) EveMonClient.MonitoredCharacterCollectionChanged += EveMonClient_MonitoredCharacterCollectionChanged; EveMonClient.CharacterUpdated += EveMonClient_CharacterUpdated; EveMonClient.SettingsChanged += EveMonClient_SettingsChanged; + EveMonClient.TimerTick += EveMonClient_TimerTick; Disposed += OnDisposed; } @@ -90,6 +92,7 @@ private void OnDisposed(object sender, EventArgs e) EveMonClient.MonitoredCharacterCollectionChanged -= EveMonClient_MonitoredCharacterCollectionChanged; EveMonClient.CharacterUpdated -= EveMonClient_CharacterUpdated; EveMonClient.SettingsChanged -= EveMonClient_SettingsChanged; + EveMonClient.TimerTick -= EveMonClient_TimerTick; Disposed -= OnDisposed; } @@ -394,12 +397,26 @@ private void EveMonClient_MonitoredCharacterCollectionChanged(object sender, Eve } /// - /// When aby character updates, we update the layout. + /// When any character updates, mark the overview as needing a refresh. + /// The actual update is coalesced and performed on the next TimerTick + /// to avoid hundreds of redundant rebuilds during startup. /// /// The sender. /// The instance containing the event data. private void EveMonClient_CharacterUpdated(object sender, CharacterChangedEventArgs e) { + m_pendingUpdate = true; + } + + /// + /// On each timer tick, flush any pending overview update. + /// + private void EveMonClient_TimerTick(object sender, EventArgs e) + { + if (!m_pendingUpdate) + return; + + m_pendingUpdate = false; UpdateContent(); } diff --git a/src/EVEMon/MainWindow.cs b/src/EVEMon/MainWindow.cs index 9a2b12e23..44f45af86 100644 --- a/src/EVEMon/MainWindow.cs +++ b/src/EVEMon/MainWindow.cs @@ -448,51 +448,52 @@ private void LayoutTabPages() { TabPage selectedTab = tcCharacterTabs.SelectedTab; - // Collect the existing pages - Dictionary pages = tcCharacterTabs.TabPages.Cast().Where( + // Collect the existing pages keyed by character + Dictionary existingPages = tcCharacterTabs.TabPages.Cast().Where( page => page.Tag is Character).ToDictionary(page => (Character)page.Tag); - // Rebuild the pages - int index = 0; + // Build the desired tab order in memory first, reusing existing pages + // and creating new ones as needed — avoids per-tab Insert/Remove layout churn + var desiredPages = new List(); foreach (Character character in EveMonClient.MonitoredCharacters) { - // Retrieve the current page, or null if we're past the limits - TabPage currentPage = index < tcCharacterTabs.TabCount ? tcCharacterTabs.TabPages[index] : null; - - // Is it the overview ? We'll deal with it later - if (currentPage == tpOverview) - currentPage = ++index < tcCharacterTabs.TabCount ? tcCharacterTabs.TabPages[index] : null; - - // Does the page match with the character ? - if ((Character)currentPage?.Tag == character) - // Update the text in case label changed - currentPage.Text = character.LabelPrefix + character.Name; + TabPage page; + if (existingPages.TryGetValue(character, out page)) + { + // Reuse existing page, update text in case label changed + page.Text = character.LabelPrefix + character.Name; + existingPages.Remove(character); + } else { - // Retrieve the page when it was previously created - // Is the page later in the collection ? - TabPage page; - if (pages.TryGetValue(character, out page)) - tcCharacterTabs.TabPages.Remove(page); // Remove the page from old location - else - page = CreateTabPage(character); // Create a new page - - // Inserts the page in the proper location - tcCharacterTabs.TabPages.Insert(index, page); + // Create a new lightweight page + page = CreateTabPage(character); } + desiredPages.Add(page); + } - // Remove processed character from the dictionary and move forward - if (character != null) - pages.Remove(character); - - index++; + // Insert the overview tab at the correct position + if (tpOverview != null && Settings.UI.MainWindow.ShowOverview) + { + int overviewIndex = Math.Max(0, Math.Min(desiredPages.Count, + Settings.UI.MainWindow.OverviewIndex)); + desiredPages.Insert(overviewIndex, tpOverview); } - // Ensures the overview has been added when necessary - AddOverviewTab(); + // Replace all tabs in one batch — much faster than individual Insert/Remove + tcCharacterTabs.SuspendLayout(); + try + { + tcCharacterTabs.TabPages.Clear(); + tcCharacterTabs.TabPages.AddRange(desiredPages.ToArray()); + } + finally + { + tcCharacterTabs.ResumeLayout(false); + } - // Dispose the removed tabs - foreach (TabPage page in pages.Values) + // Dispose the removed tabs (pages no longer in the desired set) + foreach (TabPage page in existingPages.Values) { page.Dispose(); } @@ -510,43 +511,6 @@ private void LayoutTabPages() } } - /// - /// Adds the overview tab. - /// - private void AddOverviewTab() - { - if (tpOverview == null) - return; - - if (Settings.UI.MainWindow.ShowOverview) - { - // Trim the overview page index - int overviewIndex = Math.Max(0, Math.Min(tcCharacterTabs.TabCount - 1, - Settings.UI.MainWindow.OverviewIndex)); - - // Inserts it if it doesn't exist - if (!tcCharacterTabs.TabPages.Contains(tpOverview)) - tcCharacterTabs.TabPages.Insert(overviewIndex, tpOverview); - - // If it exist insert it at the correct position - if (tcCharacterTabs.TabPages.IndexOf(tpOverview) != overviewIndex) - { - tcCharacterTabs.TabPages.Remove(tpOverview); - tcCharacterTabs.TabPages.Insert(overviewIndex, tpOverview); - } - - // Select the Overview tab if it's the only tab - if (tcCharacterTabs.TabCount == 1) - tcCharacterTabs.SelectedTab = tpOverview; - - return; - } - - // Or remove it when it should not be here anymore - if (tcCharacterTabs.TabPages.Contains(tpOverview)) - tcCharacterTabs.TabPages.Remove(tpOverview); - } - /// /// Creates the tab page for the given character. /// @@ -554,7 +518,9 @@ private void AddOverviewTab() /// A tab page. private static TabPage CreateTabPage(Character character) { - // Create the tab + // Create the tab without the heavy CharacterMonitor control. + // The monitor is created lazily when the tab is first selected, + // which dramatically speeds up startup with many characters. TabPage page; TabPage tempPage = null; try @@ -564,9 +530,6 @@ private static TabPage CreateTabPage(Character character) tempPage.Padding = new Padding(5); tempPage.Tag = character; - // Create the character monitor - CreateCharacterMonitor(character, tempPage); - page = tempPage; tempPage = null; } @@ -626,6 +589,8 @@ private void tcCharacterTabs_DragDrop(object sender, DragEventArgs e) /// private void tcCharacterTabs_SelectedIndexChanged(object sender, EventArgs e) { + // Ensure the CharacterMonitor is created for the newly selected tab + GetCurrentMonitor(); UpdateControlsOnTabSelectionChange(); } @@ -669,10 +634,30 @@ private void overview_CharacterClicked(object sender, CharacterChangedEventArgs /// private CharacterMonitor GetCurrentMonitor() { - if (tcCharacterTabs.SelectedTab == null || tcCharacterTabs.SelectedTab.Controls.Count == 0) + if (tcCharacterTabs.SelectedTab == null || tcCharacterTabs.SelectedTab == tpOverview) return null; - return tcCharacterTabs.SelectedTab.Controls[0] as CharacterMonitor; + // Lazily create the CharacterMonitor on first access to avoid + // creating ~100 controls per character during startup + TabPage tab = tcCharacterTabs.SelectedTab; + Character character = tab.Tag as Character; + if (character == null) + return null; + + if (tab.Controls.Count == 0) + { + CreateCharacterMonitor(character, tab); + + // Force OnVisibleChanged to fire on the newly created monitor so that + // all sub-controls (header, body, footer) populate their data. + // Without this, the controls miss the initial update because OnLoad + // fires before the control is fully visible in the layout. + var monitor = tab.Controls[0]; + monitor.Visible = false; + monitor.Visible = true; + } + + return tab.Controls[0] as CharacterMonitor; } /// @@ -2064,6 +2049,7 @@ private void ClearNotifications() // Clear all character monitor notifications foreach (CharacterMonitor monitor in tcCharacterTabs.TabPages.Cast() + .Where(tabPage => tabPage.Controls.Count > 0) .Select(tabPage => tabPage.Controls[0] as CharacterMonitor)) { monitor?.ClearNotifications();