// Copyright 2025 Takuto Nakamura // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. using RunCat365.Properties; using System.ComponentModel; namespace RunCat365 { internal class ContextMenuManager : IDisposable { private readonly CustomToolStripMenuItem systemInfoMenu = new(); private readonly NotifyIcon notifyIcon = new(); private readonly List icons = []; private readonly Lock iconLock = new(); private int current = 0; private EndlessGameForm? endlessGameForm; private CustomRunnerForm? customRunnerForm; private List? customRunnerSourceFrames; internal ContextMenuManager( Func getRunner, Action setRunner, CustomRunnerRepository customRunnerRepository, Func getCustomRunnerName, Action applyCustomRunner, Action onCustomRunnerDeleted, Func getSystemTheme, Func getManualTheme, Action setManualTheme, Func getSpeedSource, Action setSpeedSource, Func isSpeedSourceAvailable, Func getFPSMaxLimit, Action setFPSMaxLimit, Func getTemperatureUnit, Action setTemperatureUnit, Func getLaunchAtStartup, Func toggleLaunchAtStartup, Action openProjectPage, Action onExit ) { systemInfoMenu.Text = "-\n-\n-\n-\n-"; systemInfoMenu.Enabled = false; var runnersMenu = new CustomToolStripMenuItem(Strings.Menu_Runner); runnersMenu.SetupSubMenusFromEnum( r => r.GetLocalizedString(), (parent, sender, e) => { HandleMenuItemSelection( parent, sender, (string? s, out Runner r) => Enum.TryParse(s, out r), r => setRunner(r) ); SetIcons(getSystemTheme(), getManualTheme(), getRunner()); }, r => getCustomRunnerName() is null && getRunner() == r, r => GetRunnerThumbnailBitmap(getSystemTheme(), r) ); runnersMenu.DropDownOpening += (sender, e) => RefreshCustomRunnerMenu( runnersMenu, customRunnerRepository, ResolveTheme(getSystemTheme(), getManualTheme()), getRunner, getCustomRunnerName, applyCustomRunner ); var themeMenu = new CustomToolStripMenuItem(Strings.Menu_Theme); themeMenu.SetupSubMenusFromEnum( t => t.GetLocalizedString(), (parent, sender, e) => { HandleMenuItemSelection( parent, sender, (string? s, out Theme t) => Enum.TryParse(s, out t), t => setManualTheme(t) ); SetIcons(getSystemTheme(), getManualTheme(), getRunner()); }, t => getManualTheme() == t, _ => null ); var speedSourceMenu = new CustomToolStripMenuItem(Strings.Menu_SpeedSource); speedSourceMenu.SetupSubMenusFromEnum( s => s.GetLocalizedString(), (parent, sender, e) => { HandleMenuItemSelection( parent, sender, (string? s, out SpeedSource ss) => Enum.TryParse(s, out ss), s => setSpeedSource(s) ); }, s => getSpeedSource() == s, _ => null, isSpeedSourceAvailable ); var fpsMaxLimitMenu = new CustomToolStripMenuItem(Strings.Menu_FPSMaxLimit); fpsMaxLimitMenu.SetupSubMenusFromEnum( f => f.GetString(), (parent, sender, e) => { HandleMenuItemSelection( parent, sender, (string? s, out FPSMaxLimit f) => FPSMaxLimitExtension.TryParse(s, out f), f => setFPSMaxLimit(f) ); }, f => getFPSMaxLimit() == f, _ => null ); var temperatureUnitMenu = new CustomToolStripMenuItem(Strings.Menu_TemperatureUnit); temperatureUnitMenu.SetupSubMenusFromEnum( u => u.GetLocalizedString(), (parent, sender, e) => { HandleMenuItemSelection( parent, sender, (string? s, out TemperatureUnit u) => Enum.TryParse(s, out u), u => setTemperatureUnit(u) ); }, u => getTemperatureUnit() == u, _ => null ); var launchAtStartupMenu = new CustomToolStripMenuItem(Strings.Menu_LaunchAtStartup) { Checked = getLaunchAtStartup() }; launchAtStartupMenu.Click += (sender, e) => HandleStartupMenuClick(sender, toggleLaunchAtStartup); var settingsMenu = new CustomToolStripMenuItem(Strings.Menu_Settings); settingsMenu.DropDownItems.AddRange( themeMenu, speedSourceMenu, fpsMaxLimitMenu, temperatureUnitMenu, launchAtStartupMenu ); var customRunnersMenu = new CustomToolStripMenuItem(Strings.Menu_CustomRunners); customRunnersMenu.Click += (sender, e) => ShowOrActivateCustomRunnerWindow( customRunnerRepository, onCustomRunnerDeleted ); var endlessGameMenu = new CustomToolStripMenuItem(Strings.Menu_EndlessGame); endlessGameMenu.Click += (sender, e) => ShowOrActivateGameWindow(getSystemTheme); var appVersionMenu = new CustomToolStripMenuItem( $"{Application.ProductName} v{Application.ProductVersion}" ) { Enabled = false }; var projectPageMenu = new CustomToolStripMenuItem(Strings.Menu_OpenProjectPage); projectPageMenu.Click += (sender, e) => openProjectPage(); var informationMenu = new CustomToolStripMenuItem(Strings.Menu_Information); informationMenu.DropDownItems.AddRange( appVersionMenu, projectPageMenu ); var exitMenu = new CustomToolStripMenuItem(Strings.Menu_Exit); exitMenu.Click += (sender, e) => onExit(); var contextMenuStrip = new ContextMenuStrip(new Container()); contextMenuStrip.Items.AddRange( systemInfoMenu, new ToolStripSeparator(), runnersMenu, customRunnersMenu, new ToolStripSeparator(), settingsMenu, informationMenu, endlessGameMenu, new ToolStripSeparator(), exitMenu ); contextMenuStrip.Renderer = new ContextMenuRenderer(); SetIcons(getSystemTheme(), getManualTheme(), getRunner()); notifyIcon.Visible = true; notifyIcon.ContextMenuStrip = contextMenuStrip; } private static void HandleMenuItemSelection( ToolStripMenuItem parentMenu, object? sender, CustomTryParseDelegate tryParseMethod, Action assignValueAction ) { if (sender is null) return; var item = (ToolStripMenuItem)sender; foreach (ToolStripItem childItem in parentMenu.DropDownItems) { if (childItem is ToolStripMenuItem menuItem) { menuItem.Checked = false; } } item.Checked = true; if (item.Tag is T tagValue) { assignValueAction(tagValue); } else if (tryParseMethod(item.Text, out T parsedValue)) { assignValueAction(parsedValue); } } private static Bitmap? GetRunnerThumbnailBitmap(Theme systemTheme, Runner runner) { var color = systemTheme.GetContrastColor(); var iconName = $"{runner.GetString()}_0".ToLower(); var obj = Resources.ResourceManager.GetObject(iconName); if (obj is not Bitmap bitmap) return null; return systemTheme == Theme.Light ? bitmap : bitmap.Recolor(color); } private static void RefreshCustomRunnerMenu( CustomToolStripMenuItem runnersMenu, CustomRunnerRepository customRunnerRepository, Theme theme, Func getRunner, Func getCustomRunnerName, Action applyCustomRunner ) { foreach (ToolStripItem item in runnersMenu.DropDownItems) { if (item is ToolStripMenuItem menuItem && item.Tag is Runner runner) { menuItem.Checked = getCustomRunnerName() is null && getRunner() == runner; } } var customItems = runnersMenu.DropDownItems .Cast() .Where(item => item.Tag is CustomRunnerMenuTag) .ToList(); foreach (var item in customItems) { runnersMenu.DropDownItems.Remove(item); if (item is ToolStripMenuItem menuItem) { menuItem.Image?.Dispose(); } item.Dispose(); } var profiles = customRunnerRepository.GetAll().OrderBy(p => p.Name).ToList(); if (profiles.Count == 0) return; runnersMenu.DropDownItems.Add(new ToolStripSeparator { Tag = new CustomRunnerMenuTag("") }); foreach (var profile in profiles) { var name = profile.Name; var item = new CustomToolStripMenuItem(name) { Tag = new CustomRunnerMenuTag(name), Checked = string.Equals(getCustomRunnerName(), name, StringComparison.OrdinalIgnoreCase), Image = CreateCustomRunnerThumbnail(customRunnerRepository, name, theme) }; item.Click += (sender, e) => applyCustomRunner(name); runnersMenu.DropDownItems.Add(item); } } private static Bitmap? CreateCustomRunnerThumbnail(CustomRunnerRepository repository, string name, Theme theme) { var firstFrame = repository.LoadFirstFrame(name); if (firstFrame is null) return null; if (theme == Theme.Light) return firstFrame; using (firstFrame) { return firstFrame.Recolor(theme.GetContrastColor()); } } internal void SetIcons(Theme systemTheme, Theme manualTheme, Runner runner) { ClearCustomRunnerSourceFrames(); var runnerName = runner.GetString(); var rm = Resources.ResourceManager; var capacity = runner.GetFrameNumber(); var bitmaps = new List(capacity); for (int i = 0; i < capacity; i++) { var iconName = $"{runnerName}_{i}".ToLower(); if (rm.GetObject(iconName) is Bitmap bitmap) { bitmaps.Add(bitmap); } } ReplaceIconList(bitmaps, ResolveTheme(systemTheme, manualTheme)); } internal void SetCustomIcons(List frames, Theme systemTheme, Theme manualTheme) { ClearCustomRunnerSourceFrames(); customRunnerSourceFrames = frames.Select(f => new Bitmap(f)).ToList(); ReplaceIconList(customRunnerSourceFrames, ResolveTheme(systemTheme, manualTheme)); } internal void RecolorActiveCustomIcons(Theme systemTheme, Theme manualTheme) { if (customRunnerSourceFrames is null) return; ReplaceIconList(customRunnerSourceFrames, ResolveTheme(systemTheme, manualTheme)); } internal bool HasActiveCustomIcons => customRunnerSourceFrames is not null; private void ReplaceIconList(IList frames, Theme theme) { var color = theme.GetContrastColor(); var list = new List(frames.Count); foreach (var frame in frames) { if (theme == Theme.Light) { list.Add(frame.ToIcon()); } else { using var recolored = frame.Recolor(color); list.Add(recolored.ToIcon()); } } List oldIcons; lock (iconLock) { oldIcons = new List(icons); icons.Clear(); icons.AddRange(list); current = 0; if (icons.Count > 0) { notifyIcon.Icon = icons[0]; current = 1 % icons.Count; } } foreach (var icon in oldIcons) icon.Dispose(); } private void ClearCustomRunnerSourceFrames() { if (customRunnerSourceFrames is null) return; foreach (var bitmap in customRunnerSourceFrames) bitmap.Dispose(); customRunnerSourceFrames = null; } private static Theme ResolveTheme(Theme systemTheme, Theme manualTheme) { return manualTheme == Theme.System ? systemTheme : manualTheme; } private static void HandleStartupMenuClick(object? sender, Func toggleLaunchAtStartup) { if (sender is null) return; var item = (ToolStripMenuItem)sender; try { if (toggleLaunchAtStartup(item.Checked)) { item.Checked = !item.Checked; } } catch (InvalidOperationException ex) { MessageBox.Show(ex.Message, Strings.Message_Warning, MessageBoxButtons.OK, MessageBoxIcon.Warning); } } private void ShowOrActivateGameWindow(Func getSystemTheme) { if (endlessGameForm is null) { endlessGameForm = new EndlessGameForm(getSystemTheme()); endlessGameForm.FormClosed += (sender, e) => { endlessGameForm = null; }; endlessGameForm.Show(); } else { endlessGameForm.Activate(); } } private void ShowOrActivateCustomRunnerWindow( CustomRunnerRepository repository, Action onCustomRunnerDeleted ) { if (customRunnerForm is null) { customRunnerForm = new CustomRunnerForm(repository, onCustomRunnerDeleted); customRunnerForm.FormClosed += (sender, e) => { customRunnerForm = null; }; customRunnerForm.Show(); } else { customRunnerForm.Activate(); } } internal void ShowBalloonTip(BalloonTipType balloonTipType) { var info = balloonTipType.GetInfo(); notifyIcon.ShowBalloonTip(5000, info.Title, info.Text, info.Icon); } internal void AdvanceFrame() { lock (iconLock) { if (icons.Count == 0) return; if (icons.Count <= current) current = 0; notifyIcon.Icon = icons[current]; current = (current + 1) % icons.Count; } } internal void SetSystemInfoMenuText(string text) { systemInfoMenu.Text = text; } internal void SetNotifyIconText(string text) { notifyIcon.Text = text; } internal void HideNotifyIcon() { notifyIcon.Visible = false; } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (disposing) { lock (iconLock) { foreach (var icon in icons) icon.Dispose(); icons.Clear(); } ClearCustomRunnerSourceFrames(); if (notifyIcon is not null) { notifyIcon.ContextMenuStrip?.Dispose(); notifyIcon.Dispose(); } endlessGameForm?.Dispose(); customRunnerForm?.Dispose(); } } private delegate bool CustomTryParseDelegate(string? value, out T result); private sealed record CustomRunnerMenuTag(string Name); } }