#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 } }