mirror of
https://git.do.srb2.org/STJr/UltimateZoneBuilder.git
synced 2024-11-23 20:32:34 +00:00
461 lines
15 KiB
C#
461 lines
15 KiB
C#
#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<http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
#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<Actions.Action> recentactions;
|
|
|
|
#endregion
|
|
|
|
#region ================== Constructor
|
|
|
|
public CommandPaletteControl()
|
|
{
|
|
InitializeComponent();
|
|
|
|
recentactions = new List<Actions.Action>();
|
|
|
|
Enabled = false;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region ================== Methods
|
|
|
|
/// <summary>
|
|
/// Hides the palette. Disabled it and sends it to the background.
|
|
/// </summary>
|
|
/// <param name="sender">The sender</param>
|
|
/// <param name="e">The event args</param>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the color of the currently selected item.
|
|
/// </summary>
|
|
private void HighlightSelectedItem()
|
|
{
|
|
if (commandlist.SelectedItems.Count > 0)
|
|
{
|
|
commandlist.SelectedItems[0].BackColor = SystemColors.Highlight;
|
|
commandlist.SelectedItems[0].ForeColor = SystemColors.HighlightText;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shows the palette
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Keeps the control positioned in the top middle of the window when it is rezied.
|
|
/// </summary>
|
|
/// <param name="sender">The sender</param>
|
|
/// <param name="e">The event args</param>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Selects the item before or after the current item in the command list.
|
|
/// </summary>
|
|
/// <param name="changeindexby">By how much the index should be changed. Positive numbers mean that it will scroll up, negative numbers will scroll down.</param>
|
|
/// <param name="wraparound">If the selection should wrap around to the opposite side if the top or bottom of the list is reached</param>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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)
|
|
/// </summary>
|
|
/// <param name="text">The string to search in</param>
|
|
/// <param name="search">The string to search for</param>
|
|
/// <returns></returns>
|
|
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<string> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds an action to the command list, either in the "usable" or "unsuable" group.
|
|
/// </summary>
|
|
/// <param name="action">The action to add</param>
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs an action and adds it to the list of recent actions
|
|
/// </summary>
|
|
/// <param name="action"></param>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fills the control, filtering it so that only the actions that match the search string are shown.
|
|
/// </summary>
|
|
/// <param name="searchtext">Text to search for in the action name</param>
|
|
/// <param name="withrecent">If recently shown actions should be shown or not</param>
|
|
private void FillCommandList(string searchtext = "", bool withrecent = false)
|
|
{
|
|
List<Actions.Action> usableactions = new List<Actions.Action>();
|
|
List<Actions.Action> unusableactions = new List<Actions.Action>();
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles certain special keys. Esc will close the palette, the Up and Down keys will change the selection, and Enter will start the command.
|
|
/// </summary>
|
|
/// <param name="sender">The sender</param>
|
|
/// <param name="e">The event args</param>
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Run the command that was clicked on
|
|
/// </summary>
|
|
/// <param name="sender">The sender</param>
|
|
/// <param name="e">The event args</param>
|
|
private void commandlist_ItemActivate(object sender, EventArgs e)
|
|
{
|
|
HidePalette(this, EventArgs.Empty);
|
|
|
|
RunAction((Actions.Action)commandlist.SelectedItems[0].Tag);
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|