diff --git a/Build/Updater.ini b/Build/Updater.ini index 1862b51a..a93329d2 100755 --- a/Build/Updater.ini +++ b/Build/Updater.ini @@ -1,5 +1,5 @@ URL http://devbuilds.drdteam.org/ultimatedoombuilder/ FileName Builder.exe -UpdateName UltimateDoomBuilder-r[REVNUM]-x64.7z -InstallerName UltimateDoomBuilder-Setup-R[REVNUM]-x64.exe -UpdaterName UDB_Updater-x64.7z \ No newline at end of file +UpdateName UltimateDoomBuilder-r[REVNUM]-x86.7z +InstallerName UltimateDoomBuilder-Setup-R[REVNUM]-x86.exe +UpdaterName UDB_Updater-x86.7z \ No newline at end of file diff --git a/Source/Core/Builder.csproj b/Source/Core/Builder.csproj index 2a8ab46f..984196e8 100644 --- a/Source/Core/Builder.csproj +++ b/Source/Core/Builder.csproj @@ -180,6 +180,12 @@ ArgumentBox.cs + + UserControl + + + CommandPaletteControl.cs + UserControl @@ -669,6 +675,9 @@ + + CommandPaletteControl.cs + ExternalCommandControl.cs diff --git a/Source/Core/BuilderMono.csproj b/Source/Core/BuilderMono.csproj index 980b8d4e..5bb51403 100644 --- a/Source/Core/BuilderMono.csproj +++ b/Source/Core/BuilderMono.csproj @@ -177,6 +177,12 @@ ArgumentBox.cs + + UserControl + + + CommandPaletteControl.cs + UserControl @@ -661,6 +667,9 @@ + + CommandPaletteControl.cs + ExternalCommandControl.cs diff --git a/Source/Core/Controls/CommandPaletteControl.Designer.cs b/Source/Core/Controls/CommandPaletteControl.Designer.cs new file mode 100644 index 00000000..7b7be86d --- /dev/null +++ b/Source/Core/Controls/CommandPaletteControl.Designer.cs @@ -0,0 +1,136 @@ + +namespace CodeImp.DoomBuilder.Controls +{ + partial class CommandPaletteControl + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + System.Windows.Forms.ListViewGroup listViewGroup1 = new System.Windows.Forms.ListViewGroup("Recent", System.Windows.Forms.HorizontalAlignment.Left); + System.Windows.Forms.ListViewGroup listViewGroup2 = new System.Windows.Forms.ListViewGroup("Usable actions", System.Windows.Forms.HorizontalAlignment.Left); + System.Windows.Forms.ListViewGroup listViewGroup3 = new System.Windows.Forms.ListViewGroup("Not usable in this context", System.Windows.Forms.HorizontalAlignment.Left); + this.commandsearch = new System.Windows.Forms.TextBox(); + this.noresults = new System.Windows.Forms.Label(); + this.commandlist = new CodeImp.DoomBuilder.Controls.OptimizedListView(); + this.columnHeader1 = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader())); + this.columnHeader3 = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader())); + this.columnHeader2 = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader())); + this.SuspendLayout(); + // + // commandsearch + // + this.commandsearch.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.commandsearch.Location = new System.Drawing.Point(3, 2); + this.commandsearch.Name = "commandsearch"; + this.commandsearch.Size = new System.Drawing.Size(864, 20); + this.commandsearch.TabIndex = 2; + this.commandsearch.TextChanged += new System.EventHandler(this.commandsearch_TextChanged); + this.commandsearch.KeyDown += new System.Windows.Forms.KeyEventHandler(this.commandsearch_KeyDown); + // + // noresults + // + this.noresults.AutoSize = true; + this.noresults.Location = new System.Drawing.Point(6, 28); + this.noresults.Name = "noresults"; + this.noresults.Size = new System.Drawing.Size(84, 13); + this.noresults.TabIndex = 4; + this.noresults.Text = "No results found"; + this.noresults.Visible = false; + // + // commandlist + // + this.commandlist.Activation = System.Windows.Forms.ItemActivation.OneClick; + this.commandlist.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.commandlist.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] { + this.columnHeader1, + this.columnHeader3, + this.columnHeader2}); + this.commandlist.FullRowSelect = true; + listViewGroup1.Header = "Recent"; + listViewGroup1.Name = "recent"; + listViewGroup2.Header = "Usable actions"; + listViewGroup2.Name = "usableactions"; + listViewGroup3.Header = "Not usable in this context"; + listViewGroup3.Name = "notusableactions"; + this.commandlist.Groups.AddRange(new System.Windows.Forms.ListViewGroup[] { + listViewGroup1, + listViewGroup2, + listViewGroup3}); + this.commandlist.HeaderStyle = System.Windows.Forms.ColumnHeaderStyle.None; + this.commandlist.Location = new System.Drawing.Point(3, 25); + this.commandlist.MultiSelect = false; + this.commandlist.Name = "commandlist"; + this.commandlist.Size = new System.Drawing.Size(864, 173); + this.commandlist.TabIndex = 3; + this.commandlist.TabStop = false; + this.commandlist.UseCompatibleStateImageBehavior = false; + this.commandlist.View = System.Windows.Forms.View.Details; + this.commandlist.ItemActivate += new System.EventHandler(this.commandlist_ItemActivate); + // + // columnHeader1 + // + this.columnHeader1.Text = "Action"; + this.columnHeader1.Width = 275; + // + // columnHeader3 + // + this.columnHeader3.Text = "Section"; + this.columnHeader3.Width = 196; + // + // columnHeader2 + // + this.columnHeader2.Text = "Key"; + this.columnHeader2.TextAlign = System.Windows.Forms.HorizontalAlignment.Right; + this.columnHeader2.Width = 117; + // + // CommandPaletteControl + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.Controls.Add(this.noresults); + this.Controls.Add(this.commandlist); + this.Controls.Add(this.commandsearch); + this.DoubleBuffered = true; + this.Name = "CommandPaletteControl"; + this.Size = new System.Drawing.Size(870, 201); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private OptimizedListView commandlist; + private System.Windows.Forms.ColumnHeader columnHeader1; + private System.Windows.Forms.ColumnHeader columnHeader2; + private System.Windows.Forms.TextBox commandsearch; + private System.Windows.Forms.ColumnHeader columnHeader3; + private System.Windows.Forms.Label noresults; + } +} diff --git a/Source/Core/Controls/CommandPaletteControl.cs b/Source/Core/Controls/CommandPaletteControl.cs new file mode 100644 index 00000000..6f680309 --- /dev/null +++ b/Source/Core/Controls/CommandPaletteControl.cs @@ -0,0 +1,461 @@ +#region ================== Copyright (c) 2022 Boris Iwanski + +/* + * This program is free software: you can redistribute it and/or modify + * + * it under the terms of the GNU General Public License as published by + * + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the + * + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.If not, see. + */ + +#endregion + +#region ================== Namespaces + +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Text.RegularExpressions; +using System.Windows.Forms; +using CodeImp.DoomBuilder.Windows; + +#endregion + +namespace CodeImp.DoomBuilder.Controls +{ + public partial class CommandPaletteControl : UserControl + { + #region ================== Constants + + private const int MAX_ITEMS = 20; + private const int MAX_RECENT_ACTIONS = 5; + private const int GROUP_RECENT = 0; + private const int GROUP_USABLE = 1; + private const int GROUP_UNUSABLE = 2; + + + #endregion + + #region ================== Variables + + private readonly List recentactions; + + #endregion + + #region ================== Constructor + + public CommandPaletteControl() + { + InitializeComponent(); + + recentactions = new List(); + + Enabled = false; + } + + #endregion + + #region ================== Methods + + /// + /// Hides the palette. Disabled it and sends it to the background. + /// + /// The sender + /// The event args + private void HidePalette(object sender, EventArgs e) + { + commandsearch.LostFocus -= HidePalette; + Enabled = false; + + if (Parent is MainForm mf) + { + mf.Resize -= Reposition; + + mf.Controls.SetChildIndex(this, 0xffff); + mf.ActiveControl = null; + mf.Focus(); + } + } + + /// + /// Sets the color of the currently selected item. + /// + private void HighlightSelectedItem() + { + if (commandlist.SelectedItems.Count > 0) + { + commandlist.SelectedItems[0].BackColor = SystemColors.Highlight; + commandlist.SelectedItems[0].ForeColor = SystemColors.HighlightText; + } + } + + /// + /// Shows the palette + /// + public void MakeVisible() + { + if (Parent is MainForm mf) + { + // Reset everything to a blank slate + commandsearch.Text = string.Empty; + // commandsearch_TextChanged(this, EventArgs.Empty); + FillCommandList(withrecent: true); + HighlightSelectedItem(); + + // Set the width of each column to the max width of its fields + commandlist.Columns[0].Width = -1; + commandlist.Columns[1].Width = -1; + commandlist.Columns[2].Width = -1; + + // Compute the new width. It's the width of the columns, the vertical scroll bar and some buffer + Width = commandlist.Columns[0].Width + commandlist.Columns[1].Width + commandlist.Columns[2].Width + SystemInformation.VerticalScrollBarWidth + commandlist.Location.X * 4; + + // Center the control at the top middle + Location = new Point(mf.Display.Width / 2 - Width / 2, mf.Display.Location.Y + 5); + + Enabled = true; + + commandsearch.Focus(); + + // We want to hide the control when the focus is lost + commandsearch.LostFocus += HidePalette; + + // Bring it to the foreground + mf.Controls.SetChildIndex(this, 0); + + // Always keep the control in the center + mf.Resize += Reposition; + } + } + + /// + /// Keeps the control positioned in the top middle of the window when it is rezied. + /// + /// The sender + /// The event args + private void Reposition(object sender, EventArgs e) + { + // Center the control at the top middle + if (Parent is MainForm mf) + Location = new Point(mf.Display.Width / 2 - Width / 2, mf.Display.Location.Y + 5); + } + + /// + /// Selects the item before or after the current item in the command list. + /// + /// By how much the index should be changed. Positive numbers mean that it will scroll up, negative numbers will scroll down. + /// If the selection should wrap around to the opposite side if the top or bottom of the list is reached + private void SetSelectedItem(int changeindexby, bool wraparound) + { + if (commandlist.Items.Count > 1) + { + int newindex = commandlist.SelectedIndices[0] + changeindexby; + + if (newindex >= commandlist.Items.Count) + { + if (wraparound) + newindex = 0; + else + newindex = commandlist.Items.Count - 1; + } + else if (newindex < 0) + { + if (wraparound) + newindex = commandlist.Items.Count - 1; + else + newindex = 0; + } + + // Reset the colors of the currently selected item to the defaults + commandlist.SelectedItems[0].BackColor = SystemColors.Window; + commandlist.SelectedItems[0].ForeColor = SystemColors.WindowText; + + // Set the new item, scroll the list to it, and set the highlight color + commandlist.Items[newindex].Selected = true; + commandlist.EnsureVisible(newindex); + HighlightSelectedItem(); + } + } + + /// + /// Checks if a search string matches a text. It replicates the behavior of Visual Stuido Code. + /// At first it tries to match the whole search string. If that didn't produce a result it'll try to match as much of the search + /// string at the *beginning* of a word in the text. If that worked the matching characters are removed from the search text and + /// all words in the text up to (including) the found word are removed. This is repeated until all characters in the search string + /// are gone. This means: + /// "le cl" matches "Toggle classic rendering" + /// ^^^^^ + /// "tore" matches "Toggle classic rendering" + /// ^^ ^^ + /// "tcl" matches "Toggle classic rendering" + /// ^ ^ ^ + /// "tof" matches "Toggle Full Brightness" + /// ^^ ^ + /// "Align Floor Textures to Front Side" + /// ^^ ^ + /// "Reset Texture Offsets" + /// ^ ^^ + /// (and a couple other) + /// + /// The string to search in + /// The string to search for + /// + private bool MatchText(string text, string search) + { + text = text.ToLowerInvariant().Trim(); + text = Regex.Replace(text, @"\s+", " "); + + search = search.ToLowerInvariant().Trim(); + search = Regex.Replace(search, @"\s+", " "); + + // Check if the search string is empty or the whole search string is in the text to search + if (string.IsNullOrWhiteSpace(search) || text.Contains(search)) + return true; + + // No match yet, so let's check if all search tokens are at the beginning of a text token. This is the same(ish?) behavior as Visual Studio Code. + // This means that searching for "op ma" will match "Open Map", but not "Open Command Palette", because the "ma" in "Command" is not in the beginning. + List textitems = text.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).ToList(); + string[] searchitems = search.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + + for(int i=0; i < searchitems.Length; i++) + { + string si = searchitems[i]; + + // If the search item is empty it means we processed all its characters, so go to the next search item + if (string.IsNullOrEmpty(si)) + continue; + + string result = null; + + // Search token not found, so try to match parts of the search token + while (si.Length > 0) + { + // Try to find the first text token that starts with the search token + result = textitems.FirstOrDefault(ti => ti.StartsWith(si)); + + // We found something, so remove the matching part of the search token and prepare processing this search token again + if (result != null) + { + searchitems[i] = searchitems[i].Remove(0, si.Length); + i--; + break; + } + + // Nothing found, so remove the last character and keep going + si = si.Remove(si.Length - 1); + } + + // Nothing found, so abort + if (result == null) + return false; + + // We found a search token (or part of it), so remove all text tokens up to including the found text token + int index = textitems.IndexOf(result); + textitems.RemoveRange(0, index + 1); + } + + // We didn't return yet, so we must have found everything + return true; + } + + /// + /// Adds an action to the command list, either in the "usable" or "unsuable" group. + /// + /// The action to add + private void AddActionToList(Actions.Action action, bool isrecent = false) + { + string actiontitle = action.Title; + string catname = string.Empty; + bool isbound = action.BeginBound || action.EndBound; + + if (General.Actions.Categories.ContainsKey(action.Category)) + catname = General.Actions.Categories[action.Category]; + + ListViewItem item = commandlist.Items.Add(action.Name, actiontitle, 0); + + // Store the action in the tag, so we can invoke the action later + item.Tag = action; + + // Add the item to the appropriate group, either the "usable" (0) or "unusable" (1) one + if (isrecent) + item.Group = commandlist.Groups[GROUP_RECENT]; + else + item.Group = commandlist.Groups[isbound ? GROUP_USABLE : GROUP_UNUSABLE]; + + item.SubItems.Add(catname); + item.SubItems.Add(Actions.Action.GetShortcutKeyDesc(action.ShortcutKey)); + } + + /// + /// Runs an action and adds it to the list of recent actions + /// + /// + private void RunAction(Actions.Action action) + { + // Remove the action (if it's in the list) and then insert it at the beginning + recentactions.Remove(action); + recentactions.Insert(0, action); + + // Remove all actions that exceed the limit of the max number of recent actions + if (recentactions.Count > MAX_RECENT_ACTIONS) + recentactions.RemoveRange(4, recentactions.Count - MAX_RECENT_ACTIONS); + + General.Actions.InvokeAction(action.Name); + } + + /// + /// Fills the control, filtering it so that only the actions that match the search string are shown. + /// + /// Text to search for in the action name + /// If recently shown actions should be shown or not + private void FillCommandList(string searchtext = "", bool withrecent = false) + { + List usableactions = new List(); + List unusableactions = new List(); + + commandlist.BeginUpdate(); + commandlist.Items.Clear(); + + Actions.Action[] actions = General.Actions.GetAllActions(); + + // Crawl through all actions and check if they are usable or not in the current context + foreach (Actions.Action a in actions) + { + if (MatchText(a.Title, searchtext)) + { + if (a.BeginBound || a.EndBound) + usableactions.Add(a); + else + unusableactions.Add(a); + } + } + + // If there are matching actions we have to change the control's height and set the default selection + if (usableactions.Count + unusableactions.Count > 0) + { + noresults.Visible = false; + commandlist.Visible = true; + + if (withrecent) + foreach (Actions.Action a in recentactions) if (a != null) AddActionToList(a, true); + + // We have to do the sorting on our own, because otherwise the groups will screw with the selection logic when pressing the up/down keys + foreach (Actions.Action a in usableactions.OrderBy(o => o.Title)) AddActionToList(a); + foreach (Actions.Action a in unusableactions.OrderBy(o => o.Title)) AddActionToList(a); + + // We want to show at most MAX_ITEMS items before having a scroll bar + int numitems = commandlist.Items.Count > MAX_ITEMS ? MAX_ITEMS : commandlist.Items.Count; + + // Get the height of a row + int itemheight = commandlist.Items[0].GetBounds(ItemBoundsPortion.Entire).Height; + + // Get the number of shown groups + int numgroups = (usableactions.Count == 0 ? 0 : 1) + (unusableactions.Count == 0 ? 0 : 1); + + // Set the new height, which is the number of items times the row height, the groups, the search textbox and some buffer + Height = itemheight * numitems + commandsearch.Height + numgroups * (int)(itemheight * 1.4) + commandlist.Location.X * 5; + + // Select the topmost item and highlight it + commandlist.Items[0].Selected = true; + HighlightSelectedItem(); + + noresults.Visible = false; + } + else // No matching actions, hide line command list and tell the user that there are no matches + { + commandlist.Visible = false; + noresults.Visible = true; + + Height = noresults.Location.Y + noresults.Height + noresults.Margin.Left * 2; + } + + commandlist.EndUpdate(); + } + + #endregion + + #region ================== Events + + private void commandsearch_TextChanged(object sender, EventArgs e) + { + string searchtext = commandsearch.Text.Trim(); + + if (string.IsNullOrWhiteSpace(searchtext)) + FillCommandList(withrecent: true); + else + FillCommandList(searchtext); + } + + /// + /// Handles certain special keys. Esc will close the palette, the Up and Down keys will change the selection, and Enter will start the command. + /// + /// The sender + /// The event args + private void commandsearch_KeyDown(object sender, KeyEventArgs e) + { + switch(e.KeyCode) + { + case Keys.Escape: + case Keys.Down: + case Keys.Up: + case Keys.PageDown: + case Keys.PageUp: + //case Keys.End: + //case Keys.Home: + case Keys.Enter: + e.Handled = true; + e.SuppressKeyPress = true; + break; + } + + if (e.KeyCode == Keys.Escape) + HidePalette(this, EventArgs.Empty); + else if (e.KeyCode == Keys.Down) + SetSelectedItem(1, true); + else if (e.KeyCode == Keys.Up) + SetSelectedItem(-1, true); + else if (e.KeyCode == Keys.PageDown) + SetSelectedItem(MAX_ITEMS - 1, false); + else if (e.KeyCode == Keys.PageUp) + SetSelectedItem(-MAX_ITEMS + 1, false); + //else if (e.KeyCode == Keys.End) + // SetSelectedItem(commandlist.Items.Count, false); + //else if (e.KeyCode == Keys.Home) + // SetSelectedItem(0, false); + else if (e.KeyCode == Keys.Enter) + { + if (commandlist.Items.Count > 0) + { + HidePalette(this, EventArgs.Empty); + RunAction((Actions.Action)commandlist.SelectedItems[0].Tag); + } + } + } + + /// + /// Run the command that was clicked on + /// + /// The sender + /// The event args + private void commandlist_ItemActivate(object sender, EventArgs e) + { + HidePalette(this, EventArgs.Empty); + + RunAction((Actions.Action)commandlist.SelectedItems[0].Tag); + } + + #endregion + } +} diff --git a/Source/Core/Controls/CommandPaletteControl.resx b/Source/Core/Controls/CommandPaletteControl.resx new file mode 100644 index 00000000..1af7de15 --- /dev/null +++ b/Source/Core/Controls/CommandPaletteControl.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/Source/Core/Resources/Actions.cfg b/Source/Core/Resources/Actions.cfg index 71d7b657..42816c23 100755 --- a/Source/Core/Resources/Actions.cfg +++ b/Source/Core/Resources/Actions.cfg @@ -1443,3 +1443,13 @@ placethingatcursor allowscroll = false; default = 131076; } + +opencommandpalette +{ + title = "Open Command Palette"; + category = "tools"; + description = "Opens the command palette."; + allowkeys = true; + allowmouse = true; + allowscroll = true; +} \ No newline at end of file diff --git a/Source/Core/Windows/MainForm.cs b/Source/Core/Windows/MainForm.cs index 65fd9762..cc29f7eb 100755 --- a/Source/Core/Windows/MainForm.cs +++ b/Source/Core/Windows/MainForm.cs @@ -159,6 +159,8 @@ namespace CodeImp.DoomBuilder.Windows //mxd. Misc drawing private Graphics graphics; + + private CommandPaletteControl commandpalette; #endregion @@ -465,11 +467,28 @@ namespace CodeImp.DoomBuilder.Windows this.Update(); } - + + // We're doing it in EndAction because it'll otherwise screw with the stored keys + [EndAction("opencommandpalette")] + public void OpenCommandPalette() + { + if (commandpalette == null) + { + // We have to add the command palette control manually because trying to use the designer will make the form explode + commandpalette = new CommandPaletteControl(); + Controls.Add(commandpalette); + + // Send it somewhere to the background + Controls.SetChildIndex(commandpalette, 0xffff); + } + + commandpalette.MakeVisible(); + } + #endregion - + #region ================== Window - + // This locks the window for updating internal void LockUpdate() { @@ -1162,7 +1181,7 @@ namespace CodeImp.DoomBuilder.Windows { General.Plugins.OnEditMouseEnter(e); General.Editing.Mode.OnMouseEnter(e); - if(Application.OpenForms.Count == 1 || editformopen) display.Focus(); //mxd + if((Application.OpenForms.Count == 1 || editformopen) && (commandpalette == null ? true : !commandpalette.Visible)) display.Focus(); //mxd } } @@ -1424,7 +1443,7 @@ namespace CodeImp.DoomBuilder.Windows if(alt) mod |= (int)Keys.Alt; if(shift) mod |= (int)Keys.Shift; if(ctrl) mod |= (int)Keys.Control; - + // Don't process any keys when they are meant for other input controls if((e.KeyData != Keys.None) && ((ActiveControl == null) || (ActiveControl == display))) { @@ -1480,7 +1499,7 @@ namespace CodeImp.DoomBuilder.Windows private void MainForm_KeyUp(object sender, KeyEventArgs e) { int mod = 0; - + // Keep key modifiers alt = e.Alt; shift = e.Shift;