diff --git a/Source/ExcelDna.IntelliSense/IntelliSenseDisplay.cs b/Source/ExcelDna.IntelliSense/IntelliSenseDisplay.cs index 9077f93..386c15c 100644 --- a/Source/ExcelDna.IntelliSense/IntelliSenseDisplay.cs +++ b/Source/ExcelDna.IntelliSense/IntelliSenseDisplay.cs @@ -20,34 +20,56 @@ class IntelliSenseDisplay : IDisposable SynchronizationContext _syncContextMain; readonly UIMonitor _uiMonitor; + readonly KeyboardEventHook _keyboardEventHook; readonly Dictionary _functionInfoMap = new Dictionary(StringComparer.CurrentCultureIgnoreCase); + List typesInAllAssemblies = AppDomain + .CurrentDomain.GetAssemblies() + .SelectMany(a => + { + try + { + return a.GetTypes(); + } + catch + { + return Array.Empty(); + } + }) + .ToList(); + + ParamDropDownForm _paramDropDown; + // Need to make these late ...? ToolTipForm _descriptionToolTip; ToolTipForm _argumentsToolTip; IntPtr _formulaEditWindow; IntPtr _functionListWindow; string _argumentSeparator = ", "; - + const int DescriptionLeftMargin = 3; - public IntelliSenseDisplay(SynchronizationContext syncContextMain, UIMonitor uiMonitor) + public IntelliSenseDisplay(SynchronizationContext syncContextMain, UIMonitor uiMonitor, KeyboardEventHook keyboardEventHook) { // We expect this to be running in a macro context on the main Excel thread (ManagedThreadId = 1). - #pragma warning disable CS0618 // Type or member is obsolete (GetCurrentThreadId) - But for debugging we want to monitor this anyway +#pragma warning disable CS0618 // Type or member is obsolete (GetCurrentThreadId) - But for debugging we want to monitor this anyway Debug.Print($"### Thread creating IntelliSenseDisplay: Managed {Thread.CurrentThread.ManagedThreadId}, Native {AppDomain.GetCurrentThreadId()}"); - #pragma warning restore CS0618 // Type or member is obsolete +#pragma warning restore CS0618 // Type or member is obsolete _syncContextMain = syncContextMain; _uiMonitor = uiMonitor; _uiMonitor.StateUpdatePreview += StateUpdatePreview; _uiMonitor.StateUpdate += StateUpdate; + _keyboardEventHook = keyboardEventHook; + _keyboardEventHook.KeyboardEventReceived += KeyboardEventReceived; + _keyboardEventHook.ShouldSwallow += ShouldSwallowKeyboardEvents; + InitializeOptions(); } - + // Runs on the main Excel thread in a macro context. void InitializeOptions() { @@ -81,6 +103,17 @@ void InitializeOptions() Logger.Initialization.Verbose($"InitializeOptions - Set StandardFontSize to {standardFontSize}"); } ToolTipForm.SetStandardFont(standardFontName, standardFontSize); + ParamDropDownForm.SetStandardFont(standardFontName, standardFontSize); + } + + internal void KeyboardEventReceived(object sender, KeyboardEventHook.KeyboardEventArgs args) + { + _paramDropDown?.OnKeyboardEvent(sender, args); + } + + internal bool ShouldSwallowKeyboardEvents() + { + return _paramDropDown?.Visible ?? false; } // TODO: Still not sure how to delete / unregister... @@ -129,22 +162,21 @@ bool ShouldProcessFunctionListSelectedItemChange(string selectedItemText) { if (_descriptionToolTip?.Visible == true) return true; - + return _functionInfoMap.ContainsKey(selectedItemText); } // Runs on the UIMonitor's automation thread - return true if we might want to process - bool ShouldProcessFormulaEditTextChange(string formulaPrefix) + bool ShouldProcessFormulaEditTextChange(FormulaEditWatcher.FormulaData formulaPrefix) { // CAREFUL: Because of threading, this might run before FormulaEditStart! - if (_argumentsToolTip?.Visible == true) + if (_argumentsToolTip?.Visible == true || _paramDropDown?.Visible == true) return true; // TODO: Why do this twice....? string functionName; - int currentArgIndex; - if (FormulaParser.TryGetFormulaInfo(formulaPrefix, out functionName, out currentArgIndex)) + if (FormulaParser.TryGetFormulaInfo(formulaPrefix, out functionName, out _, out _)) { if (_functionInfoMap.ContainsKey(functionName)) return true; @@ -205,7 +237,7 @@ void StateUpdate(object sender, UIStateUpdate update) case UIStateUpdate.UpdateType.FormulaEditEnd: FormulaEditEnd(); break; - + case UIStateUpdate.UpdateType.SelectDataSourceShow: case UIStateUpdate.UpdateType.SelectDataSourceWindowChange: case UIStateUpdate.UpdateType.SelectDataSourceHide: @@ -222,15 +254,15 @@ void UpdateFormulaEditWindow(IntPtr formulaEditWindow) if (_formulaEditWindow != formulaEditWindow) { _formulaEditWindow = formulaEditWindow; - if (_argumentsToolTip != null) - { - // Rather ChangeParent...? - _argumentsToolTip.Dispose(); - _argumentsToolTip = null; - } + // Rather ChangeParent...? + _argumentsToolTip?.Dispose(); + _argumentsToolTip = null; + _paramDropDown?.Dispose(); + _paramDropDown = null; if (_formulaEditWindow != IntPtr.Zero) { _argumentsToolTip = new ToolTipForm(_formulaEditWindow); + _paramDropDown = new ParamDropDownForm(_formulaEditWindow, _syncContextMain); //_argumentsToolTip.OwnerHandle = _formulaEditWindow; } else @@ -255,20 +287,23 @@ void UpdateFormulaEditWindow(IntPtr formulaEditWindow) // _descriptionToolTip = new ToolTipForm(_functionListWindow); // //_descriptionToolTip.OwnerHandle = _functionListWindow; // } - + // } //} // Runs on the main thread - void FormulaEditStart(string formulaPrefix, Rect editWindowBounds, IntPtr excelToolTipWindow) + void FormulaEditStart(FormulaEditWatcher.FormulaData formulaPrefix, Rect editWindowBounds, IntPtr excelToolTipWindow) { Debug.Print($"IntelliSenseDisplay - FormulaEditStart - FormulaEditWindow: {_formulaEditWindow}, ArgumentsToolTip: {_argumentsToolTip}"); - if (_formulaEditWindow != IntPtr.Zero && _argumentsToolTip == null) - _argumentsToolTip = new ToolTipForm(_formulaEditWindow); + if (_formulaEditWindow != IntPtr.Zero) + { + _argumentsToolTip ??= new ToolTipForm(_formulaEditWindow); + _paramDropDown ??= new ParamDropDownForm(_formulaEditWindow, _syncContextMain); + } // Normally we would have no formula at this point. // One exception is after mouse-click on the formula list, we then need to process it. - if (!string.IsNullOrEmpty(formulaPrefix)) + if (!string.IsNullOrEmpty(formulaPrefix.Text)) FormulaEditTextChange(formulaPrefix, editWindowBounds, excelToolTipWindow); } @@ -277,26 +312,26 @@ void FormulaEditEnd() { Debug.Print($"IntelliSenseDisplay - FormulaEditEnd"); // TODO: When can it be null - if (_argumentsToolTip != null) - { - //_argumentsToolTip.Hide(); - _argumentsToolTip.Dispose(); - _argumentsToolTip = null; - } + //_argumentsToolTip.Hide(); + _argumentsToolTip?.Dispose(); + _argumentsToolTip = null; + + _paramDropDown?.Dispose(); + _paramDropDown = null; } // Runs on the main thread void FormulaEditMove(Rect editWindowBounds, IntPtr excelToolTipWindow) { Debug.Print($"IntelliSenseDisplay - FormulaEditMove"); - if (_argumentsToolTip == null) - { - Logger.Display.Warn("FormulaEditMode Unexpected null Arguments ToolTip!?"); - return; - } int topOffset = GetTopOffset(excelToolTipWindow); try { + if (_argumentsToolTip == null) + { + Logger.Display.Warn("FormulaEditMode Unexpected null Arguments ToolTip!?"); + return; + } _argumentsToolTip.MoveToolTip((int)editWindowBounds.Left, (int)editWindowBounds.Bottom + 5, topOffset); } catch (Exception ex) @@ -306,58 +341,175 @@ void FormulaEditMove(Rect editWindowBounds, IntPtr excelToolTipWindow) _argumentsToolTip.Dispose(); _argumentsToolTip = null; } + + try + { + if (_paramDropDown == null) + { + Logger.Display.Warn("FormulaEditMode Unexpected null Arguments ParamDropDown!?"); + return; + } + _paramDropDown.MoveToolTip((int)editWindowBounds.Left, (int)editWindowBounds.Bottom + 5, topOffset); + } + catch (Exception ex) + { + Logger.Display.Warn($"IntelliSenseDisplay - FormulaEditMove Error - {ex}"); + // Recycle the Arguments ParamDropDown - won't show now, but should for the next function + _paramDropDown.Dispose(); + _paramDropDown = null; + } } // Runs on the main thread - void FormulaEditTextChange(string formulaPrefix, Rect editWindowBounds, IntPtr excelToolTipWindow) + void FormulaEditTextChange(FormulaEditWatcher.FormulaData formulaPrefix, Rect editWindowBounds, IntPtr excelToolTipWindow) { Debug.Print($"^^^ FormulaEditStateChanged. CurrentPrefix: {formulaPrefix}, Thread {Thread.CurrentThread.ManagedThreadId}"); string functionName; int currentArgIndex; - if (FormulaParser.TryGetFormulaInfo(formulaPrefix, out functionName, out currentArgIndex)) + string currentArgValue; + + var parsed = FormulaParser.TryGetFormulaInfo(formulaPrefix, out functionName, out currentArgIndex, out currentArgValue); + if (!parsed) + { + // All other paths, we hide the box + _argumentsToolTip?.Hide(); + _paramDropDown?.Hide(); + return; + } + + FunctionInfo functionInfo; + var hasFunctionInfo = _functionInfoMap.TryGetValue(functionName, out functionInfo); + if (!hasFunctionInfo) + { + // All other paths, we hide the box + _argumentsToolTip?.Hide(); + _paramDropDown?.Hide(); + return; + } + + var lineBeforeFunctionName = FormulaParser.GetLineBeforeFunctionName(formulaPrefix.Text, functionName); + // We have a function name and we want to show info + if (_argumentsToolTip != null) + { + // NOTE: Hiding or moving just once doesn't help - the tooltip pops up in its original place again + // TODO: Try to move it off-screen, behind or make invisible + //if (!_argumentsToolTip.Visible) + //{ + // // Fiddle a bit with the ExcelToolTip if it is already visible when we first show our FunctionEdit ToolTip + // // At other times, the explicit UI update should catch and hide as appropriate + // if (excelToolTipWindow != IntPtr.Zero) + // { + // Win32Helper.HideWindow(excelToolTipWindow); + // } + //} + int topOffset = GetTopOffset(excelToolTipWindow); + FormattedText infoText = GetFunctionIntelliSense(functionInfo, currentArgIndex); + try + { + _argumentsToolTip.ShowToolTip(infoText, lineBeforeFunctionName, (int)editWindowBounds.Left, (int)editWindowBounds.Bottom + 5, topOffset); + } + catch (Exception ex) + { + Logger.Display.Warn($"IntelliSenseDisplay - FormulaEditTextChange Error - {ex}"); + _argumentsToolTip.Dispose(); + _argumentsToolTip = null; + } + } + else + { + Logger.Display.Info("FormulaEditTextChange with no arguments tooltip !?"); + } + + if (_paramDropDown != null) { - FunctionInfo functionInfo; - if (_functionInfoMap.TryGetValue(functionName, out functionInfo)) + var argumentDescription = functionInfo.ArgumentList.ElementAtOrDefault(currentArgIndex); + if (argumentDescription == null) + { + _paramDropDown.Hide(); + return; + } + + var description = GetArgumentDescription(argumentDescription); + + List collectedValues = new(); + foreach (var line in description) { - var lineBeforeFunctionName = FormulaParser.GetLineBeforeFunctionName(formulaPrefix, functionName); - // We have a function name and we want to show info - if (_argumentsToolTip != null) + var splitted = line.ToString().Split(new char[] { ':', '-', ',' }); + if (splitted.Length < 2) + continue; + + var typeStr = splitted[1].Trim(' ', '?'); + if (string.IsNullOrEmpty(typeStr)) + continue; + + var type = typesInAllAssemblies.FirstOrDefault(t => t.Name == typeStr); + + if (type is null) + continue; + + if (type.IsEnum) { - // NOTE: Hiding or moving just once doesn't help - the tooltip pops up in its original place again - // TODO: Try to move it off-screen, behind or make invisible - //if (!_argumentsToolTip.Visible) - //{ - // // Fiddle a bit with the ExcelToolTip if it is already visible when we first show our FunctionEdit ToolTip - // // At other times, the explicit UI update should catch and hide as appropriate - // if (excelToolTipWindow != IntPtr.Zero) - // { - // Win32Helper.HideWindow(excelToolTipWindow); - // } - //} - int topOffset = GetTopOffset(excelToolTipWindow); - FormattedText infoText = GetFunctionIntelliSense(functionInfo, currentArgIndex); - try - { - _argumentsToolTip.ShowToolTip(infoText, lineBeforeFunctionName, (int)editWindowBounds.Left, (int)editWindowBounds.Bottom + 5, topOffset); - } - catch (Exception ex) + Array values = Enum.GetValues(type); + + foreach (var v in values) { - Logger.Display.Warn($"IntelliSenseDisplay - FormulaEditTextChange Error - {ex}"); - _argumentsToolTip.Dispose(); - _argumentsToolTip = null; + var value = v.ToString(); + collectedValues.Add(value); } } - else + + if (type == typeof(bool)) { - Logger.Display.Info("FormulaEditTextChange with no arguments tooltip !?"); + collectedValues.AddRange(new string[] { "TRUE", "FALSE" }); } + } + + if (collectedValues.Count == 0) + { + _paramDropDown.Hide(); return; } - } + FormattedText infoText = new FormattedText(); + var trimmedCurrentArgValue = currentArgValue.Trim('"', ','); + foreach (var v in collectedValues) + { + TextLine line; + if (string.IsNullOrEmpty(trimmedCurrentArgValue)) + { + line = new TextLine() { new TextRun() { Text = v } }; + } + else if (v.Contains(trimmedCurrentArgValue)) + { + line = new TextLine() { new TextRun() { Text = v } }; + } + else + continue; + infoText.Add(line); + } + + if (!infoText.Any()) + { + _paramDropDown.Hide(); + return; + } - // All other paths, we hide the box - _argumentsToolTip?.Hide(); + int topOffset = GetTopOffset(excelToolTipWindow); + try + { + _paramDropDown.ShowSelector(infoText, formulaPrefix.Text, currentArgValue, (int)editWindowBounds.Left, (int)editWindowBounds.Bottom + 5, topOffset); + } + catch (Exception ex) + { + Logger.Display.Warn($"IntelliSenseDisplay - FormulaEditTextChange Error - {ex}"); + _paramDropDown.Dispose(); + _paramDropDown = null; + } + } + else + { + Logger.Display.Info("FormulaEditTextChange with no parameter selector !?"); + } } @@ -447,17 +599,17 @@ void FunctionListSelectedItemChange(string selectedItemText, Rect selectedItemBo // Not ours or no description _descriptionToolTip?.Hide(); } - + void FunctionListMove(Rect selectedItemBounds, Rect listBounds) { try { _descriptionToolTip?.MoveToolTip( - left: (int)listBounds.Right + DescriptionLeftMargin, - top: (int)selectedItemBounds.Bottom - 18, - topOffset: 0, - listLeft: (int)selectedItemBounds.Left, - listTop: (int)selectedItemBounds.Top); + left: (int)listBounds.Right + DescriptionLeftMargin, + top: (int)selectedItemBounds.Bottom - 18, + topOffset: 0, + listLeft: (int)selectedItemBounds.Left, + listTop: (int)selectedItemBounds.Top); } catch (Exception ex) { @@ -477,8 +629,8 @@ IEnumerable GetFunctionDescriptionOrNull(FunctionInfo functionInfo) return null; return description.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n') - .Select(line => - new TextLine { + .Select(line => + new TextLine { new TextRun { Style = System.Drawing.FontStyle.Regular, @@ -546,7 +698,7 @@ FormattedText GetFunctionIntelliSense(FunctionInfo functionInfo, int currentArgI // Usually just having the prefix is OK, but in case we have the formula: F(params object[] args) and we write in the formula editor =F(1,2,3,4,5,6,7) // and then we move the cursor to point on the second argument, our current implementation will shorten its text and omit any argument after the 3rd argument. // But Excel will keep showing the vurtual argument list corresponding to the full formula. - // There is no technical problem in getting the full formula - PenHelper will give us the required info - but tracking this throughout the IntelliSense state + // There is no technical problem in getting the full formula - PenHelper will give us the required info - but tracking this throughout the IntelliSense state // affects the code in a lot of places, and the benefits seem small, particularly in this case of quirky Excel behaviour. List GetExpandedArgumentList(FunctionInfo functionInfo, int currentArgIndex) { @@ -617,6 +769,10 @@ public void Dispose() { _uiMonitor.StateUpdatePreview -= StateUpdatePreview; _uiMonitor.StateUpdate -= StateUpdate; + + _keyboardEventHook.KeyboardEventReceived -= KeyboardEventReceived; + _keyboardEventHook.ShouldSwallow -= ShouldSwallowKeyboardEvents; + //// TODO: How to interact with the pending event callbacks? //_syncContextMain.Send(delegate // { diff --git a/Source/ExcelDna.IntelliSense/IntellisenseHelper.cs b/Source/ExcelDna.IntelliSense/IntellisenseHelper.cs index dc5531b..d1f6afb 100644 --- a/Source/ExcelDna.IntelliSense/IntellisenseHelper.cs +++ b/Source/ExcelDna.IntelliSense/IntellisenseHelper.cs @@ -9,7 +9,7 @@ namespace ExcelDna.IntelliSense // This is really the running IntelliSenseServer // It brings together: // * the UIMonitor which monitors the state of Excel, including the current function prefix, - // * the IntelliSenseDisplay which presents the pop-ups, and + // * the IntelliSenseDisplay which presents the pop-ups, and // * the IntelliSenseProviders which figure out what information is available. class IntelliSenseHelper : IDisposable { @@ -18,6 +18,7 @@ class IntelliSenseHelper : IDisposable // These need to get combined into a UIEnhancement class .... readonly IntelliSenseDisplay _display; + readonly KeyboardEventHook _keyboardEventHook; readonly List _providers = new List(); // TODO: Others @@ -25,8 +26,9 @@ public IntelliSenseHelper() { Logger.Initialization.Verbose("IntelliSenseHelper Constructor Begin"); _syncContextMain = new WindowsFormsSynchronizationContext(); + _keyboardEventHook = new KeyboardEventHook(_syncContextMain); _uiMonitor = new UIMonitor(_syncContextMain); - _display = new IntelliSenseDisplay(_syncContextMain, _uiMonitor); + _display = new IntelliSenseDisplay(_syncContextMain, _uiMonitor, _keyboardEventHook); _providers = new List { diff --git a/Source/ExcelDna.IntelliSense/ParamDropDownForm.cs b/Source/ExcelDna.IntelliSense/ParamDropDownForm.cs new file mode 100644 index 0000000..00a166a --- /dev/null +++ b/Source/ExcelDna.IntelliSense/ParamDropDownForm.cs @@ -0,0 +1,450 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Linq; +using System.Threading; +using System.Windows.Forms; + +namespace ExcelDna.IntelliSense; + +public class ParamDropDownForm : Form +{ + FormattedText _text; + string _lastCurrentArgValue = ""; + int _maxTextHeight; + + TextFormatFlags _textFormatFlags = + TextFormatFlags.Left + | TextFormatFlags.Top + | TextFormatFlags.NoPadding + | TextFormatFlags.SingleLine + | TextFormatFlags.ExternalLeading; + + SynchronizationContext _syncContextMain; + + private Color _highlightColor = Color.LightBlue; + private Font _font = new Font("Segoe UI", 9f); + + Win32Window _owner; + + // Various graphics object cached + Color _textColor; + Color _linkColor; + Pen _borderPen; + Pen _borderLightPen; + Pen _selectedItemPen; + Dictionary _fonts; + + int _currentLeft; + int _currentTop; + int _showLeft; + int _showTop; + int _topOffset; // Might be trying to move the tooltip out of the way of Excel's tip - we track this extra offset here + int? _listLeft; + int? _listTop; + + bool _mouseCaptured = false; + bool _kbCaptured = false; + + static Font s_standardFont; + + int _selectedIndex = 0; + + protected override bool ShowWithoutActivation => true; + + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public IntPtr OwnerHandle + { + get + { + if (_owner == null) + return IntPtr.Zero; + return _owner.Handle; + } + set + { + if (_owner == null || _owner.Handle != value) + { + _owner = new Win32Window(value); + if (Visible) + { + // We want to change the owner. + // That's hard, so we hide and re-show. + Hide(); + ShowSelector(); + } + } + } + } + + protected override CreateParams CreateParams + { + get + { + CreateParams createParams; + const int CS_DROPSHADOW = 0x00020000; + //const int WS_CHILD = 0x40000000; + const int WS_EX_TOOLWINDOW = 0x00000080; + const int WS_EX_NOACTIVATE = 0x08000000; + // const int WS_EX_TOPMOST = 0x00000008; + const int WS_TABSTOP = 0x00010000; + // NOTE: I've seen exception with invalid handle in the base.CreateParams call here... + createParams = base.CreateParams; + createParams.ClassStyle &= ~CS_DROPSHADOW; + // baseParams.Style |= WS_CHILD; + createParams.ExStyle |= (WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW); + createParams.ExStyle &= ~WS_TABSTOP; + return createParams; + } + } + + public ParamDropDownForm(IntPtr hwndOwner, SynchronizationContext syncContextMain) + { + InitializeComponents(); + _owner = new Win32Window(hwndOwner); + _syncContextMain = syncContextMain; + // CONSIDER: Maybe make a more general solution that lazy-loads as needed + _fonts = new Dictionary + { + { FontStyle.Regular, new Font("Segoe UI", 9, FontStyle.Regular) }, + { FontStyle.Bold, new Font("Segoe UI", 9, FontStyle.Bold) }, + { FontStyle.Italic, new Font("Segoe UI", 9, FontStyle.Italic) }, + { FontStyle.Underline, new Font("Segoe UI", 9, FontStyle.Underline) }, + { FontStyle.Bold | FontStyle.Italic, new Font("Segoe UI", 9, FontStyle.Bold | FontStyle.Italic) }, + }; + _textColor = Color.FromArgb(72, 72, 72); + _linkColor = Color.Blue; + _borderPen = new Pen(Color.FromArgb(195, 195, 195)); + _borderLightPen = new Pen(Color.FromArgb(225, 225, 225)); + _selectedItemPen = new Pen(Color.FromArgb(204, 232, 255), 1.5f); + SetStyle(ControlStyles.UserMouse | ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint, true); + Debug.Print($"Created ToolTipForm with owner {hwndOwner}"); + } + + private void InitializeComponents() + { + this.SuspendLayout(); + this.StartPosition = FormStartPosition.Manual; + this.AutoSizeMode = AutoSizeMode.GrowAndShrink; + this.BackColor = Color.LightGray; + this.ClientSize = new Size(114, 20); + this.ControlBox = false; + this.DoubleBuffered = true; + this.ForeColor = Color.DimGray; + this.FormBorderStyle = FormBorderStyle.None; + this.Name = "ParamDropDownForm"; + this.ShowInTaskbar = false; + this.ResumeLayout(false); + } + + internal void OnKeyboardEvent(object sender, KeyboardEventHook.KeyboardEventArgs args) + { + if (!Visible) + { + return; + } + + Keys key = (Keys)args.Key; + var count = _text.Count(); + + switch (key) + { + case Keys.Up: + if (args.LParam.IsReleased) + { + return; + } + _selectedIndex -= 1; + if (_selectedIndex < 0) + { + _selectedIndex += count; + } + Invalidate(); + return; + case Keys.Down: + if (args.LParam.IsReleased) + { + return; + } + _selectedIndex += 1; + if (_selectedIndex > count - 1) + { + _selectedIndex -= count; + } + Invalidate(); + return; + case Keys.Tab: + if (!args.LParam.IsReleased || _kbCaptured) + { + return; + } + _kbCaptured = true; + _syncContextMain.Post(AcceptSelection, true); + return; + case Keys.Escape: + if (args.LParam.IsReleased) + { + return; + } + Hide(); + return; + } + } + + protected override void WndProc(ref Message m) + { + const int WM_MOUSEACTIVATE = 0x21; + const int WM_MOUSEMOVE = 0x0200; + const int WM_LBUTTONDOWN = 0x0201; + const int WM_LBUTTONUP = 0x0202; + const int WM_LBUTTONDBLCLK = 0x203; + const int WM_SETCURSOR = 0x20; + const int MA_NOACTIVATE = 0x0003; + // const int WM_KEYDOWN = 0x0100; + // const int WM_CHAR = 0x0102; + + switch (m.Msg) + { + // Prevent activation by mouse interaction + case WM_MOUSEACTIVATE: + m.Result = (IntPtr)MA_NOACTIVATE; + return; + // We're never active, so we need to do our own mouse handling + case WM_LBUTTONDOWN: + MouseButtonDown(); + return; + case WM_MOUSEMOVE: + MouseMoved(); + return; + case WM_LBUTTONUP: + MouseButtonUp(); + return; + case WM_SETCURSOR: + case WM_LBUTTONDBLCLK: + // We need to handle this message to prevent flicker (possibly because we're not 'active'). + m.Result = new IntPtr(1); //Signify that we dealt with the message. + return; + // case WM_KEYDOWN: + // case WM_CHAR: + // Keys key = (Keys)(int)m.WParam; + // // OnKeyDown(key); + // return; + default: + base.WndProc(ref m); + return; + } + } + + void ShowSelector() + { + try + { + Show(_owner); + } + catch (Exception e) + { + Debug.Write("ToolTipForm.Show error " + e); + } + } + + internal void ShowSelector( + FormattedText text, + string functionName, + string currentArgValue, + int left, + int top, + int topOffset, + int? listLeft = null, + int? listTop = null + ) + { + // Debug.Print($"@@@ ShowToolTip - Old TopOffset: {_topOffset}, New TopOffset: {topOffset}"); + + _text = text; + _lastCurrentArgValue = currentArgValue; + + if ( + left != _showLeft + || top != _showTop + || topOffset != _topOffset + || listLeft != _listLeft + || listTop != _listTop + ) + { + Win32Helper.TryGetCaretScreenPosition(_owner.Handle, out var p); + + // // Update the start position and the current position + _currentLeft = p.X; // Don't move off the screen + _currentTop = top; + _showLeft = _currentLeft; + _showTop = _currentTop; + _topOffset = topOffset; + _listLeft = listLeft; + _listTop = listTop; + } + if (!Visible) + { + // Debug.Print($"ShowToolTip - Showing ToolTipForm: {linePrefix} => {_text.ToString()}"); + // Make sure we're in the right position before we're first shown + _selectedIndex = 0; + SetBounds(_currentLeft, _currentTop + _topOffset, 0, 0); + // _showTimeTicks = DateTime.UtcNow.Ticks; + + ShowSelector(); + } + else + { + // Debug.Print($"ShowToolTip - Invalidating ToolTipForm: {linePrefix} => {_text.ToString()}"); + Invalidate(); + } + } + + public void MoveToolTip(int left, int top, int topOffset, int? listLeft = null, int? listTop = null) + { + Debug.Print($"@@@ MoveToolTip - Old TopOffset: {_topOffset}, New TopOffset: {topOffset}"); + Win32Helper.TryGetCaretScreenPosition(_owner.Handle, out var p); + left = p.X; + // We might consider checking the new position against earlier mouse movements + _currentLeft = left; + _currentTop = top; + _showLeft = left; + _showTop = top; + _topOffset = topOffset; + _listLeft = listLeft; + _listTop = listTop; + Invalidate(); + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + + int maxTextWidth = 0; + var numberOfItems = _text.Count(); + for (var index = 0; index < numberOfItems; index++) + { + var line = _text.ElementAt(index); + var str = line.ToString(); + var textSize = TextRenderer.MeasureText(e.Graphics, str, _font); + maxTextWidth = Math.Max(maxTextWidth, textSize.Width); + _maxTextHeight = Math.Max(_maxTextHeight, textSize.Height); + var textRect = new Rectangle(2, 2 + index * textSize.Height, textSize.Width + 2, textSize.Height + 2); + + using var brush = new SolidBrush(_highlightColor); + if (index == _selectedIndex) + { + e.Graphics.FillRectangle(brush, textRect); + // e.Graphics.DrawRectangle(_selectedItemPen, textRect); + } + TextRenderer.DrawText(e.Graphics, str, _font, textRect, _textColor, _textFormatFlags); + } + + var height = numberOfItems * _maxTextHeight + numberOfItems * 2; + var width = maxTextWidth; + SetBounds(_currentLeft, _currentTop + _topOffset, width, height); + DrawRoundedRectangle(e.Graphics, new RectangleF(0, 0, Width - 1, Height - 1), 2, 2); + } + + void DrawRoundedRectangle(Graphics g, RectangleF r, float radiusX, float radiusY) + { + var oldMode = g.SmoothingMode; + g.SmoothingMode = SmoothingMode.None; + + g.DrawRectangle(_borderLightPen, new Rectangle((int)r.X, (int)r.Y, 1, 1)); + g.DrawRectangle(_borderLightPen, new Rectangle((int)(r.X + r.Width - 1), (int)r.Y, 1, 1)); + g.DrawRectangle(_borderLightPen, new Rectangle((int)(r.X + r.Width - 1), (int)(r.Y + r.Height - 1), 1, 1)); + g.DrawRectangle(_borderLightPen, new Rectangle((int)(r.X), (int)(r.Y + r.Height - 1), 1, 1)); + g.DrawRectangle(_borderPen, new Rectangle((int)r.X, (int)r.Y, (int)r.Width, (int)r.Height)); + + g.SmoothingMode = oldMode; + } + + void MouseButtonDown() + { + _mouseCaptured = true; + Win32Helper.SetCapture(Handle); + } + + void MouseMoved() + { + if (_maxTextHeight == 0) + return; + + var relativeP = PointToClient(Cursor.Position); + var index = relativeP.Y / _maxTextHeight; + if (index >= 0 && index < _text.Count()) + { + _selectedIndex = index; + Invalidate(); + } + } + + void MouseButtonUp() + { + if (!_mouseCaptured) + { + return; + } + + _mouseCaptured = false; + Win32Helper.ReleaseCapture(); + + IDataObject oldClipboard = null; + + try + { + oldClipboard = Clipboard.GetDataObject(); + + AcceptSelection(); + } + finally + { + // Restore clipboard + if (oldClipboard != null) + { + try + { + Clipboard.SetDataObject(oldClipboard); + } + catch + { + // ignore – clipboard might be locked by another process + } + } + } + } + + private void AcceptSelection(object state = null) + { + var hide = state != null && (bool)state; + var selectedItem = _text.ElementAtOrDefault(_selectedIndex)?.ToString(); + + if (selectedItem == null) + { + return; + } + + SendKeys.SendWait($"{{BACKSPACE {_lastCurrentArgValue.Length}}}"); + + var handledSelectedItem = $"\"{selectedItem}\""; + + Clipboard.SetText(handledSelectedItem); + + SendKeys.SendWait("^v"); + + _kbCaptured = false; + + if (hide) + Hide(); + else + Invalidate(); + } + + internal static void SetStandardFont(string standardFontName, double standardFontSize) + { + s_standardFont = new Font(standardFontName, (float)standardFontSize); + } +} diff --git a/Source/ExcelDna.IntelliSense/ToolTipForm.cs b/Source/ExcelDna.IntelliSense/ToolTipForm.cs index 93027ba..aea00c3 100644 --- a/Source/ExcelDna.IntelliSense/ToolTipForm.cs +++ b/Source/ExcelDna.IntelliSense/ToolTipForm.cs @@ -23,7 +23,7 @@ class ToolTipForm : Form bool _linkActive; string _linkAddress; long _showTimeTicks; // Track to prevent mouse click-through into help - // Mouse Capture information for moving + // Mouse Capture information for moving bool _captured = false; Point _mouseDownScreenLocation; Point _mouseDownFormLocation; @@ -41,7 +41,7 @@ class ToolTipForm : Form Pen _borderPen; Pen _borderLightPen; Dictionary _fonts; - ToolTip tipDna; + // ToolTip tipDna; static Font s_standardFont; @@ -126,7 +126,7 @@ public void ShowToolTip(FormattedText text, string linePrefix, int left, int top if (left != _showLeft || top != _showTop || topOffset != _topOffset || listLeft != _listLeft || listTop != _listTop) { // TODO: Is there a way to track this and not fetch every time? or fast-track single screen? - var ownerScreen = Screen.FromHandle(_owner.Handle); + var ownerScreen = Screen.FromHandle(_owner.Handle); var workingArea = ownerScreen.WorkingArea; // Update the start position and the current position @@ -203,7 +203,7 @@ int MeasureFormulaStringWidth(string formulaString) } #region Mouse Handling - + void MouseButtonDown(Point screenLocation) { if (!_linkClientRect.Contains(PointToClient(screenLocation))) @@ -248,7 +248,7 @@ void MouseButtonUp(Point screenLocation) return; } - // This delay check of 500 ms is inserted to prevent the double-click on the formula list to also be processed + // This delay check of 500 ms is inserted to prevent the double-click on the formula list to also be processed // as a click on the toolip, launching the help erroneously. var nowTicks = DateTime.UtcNow.Ticks; if (nowTicks - _showTimeTicks < 5000000) @@ -300,7 +300,7 @@ void LaunchLink(string address) if (File.Exists(address)) { dynamic app = ExcelDna.Integration.ExcelDnaUtil.Application; - app.Help(address); + app.Help(address); // Help.ShowHelp(null, address, HelpNavigator.TableOfContents); } else @@ -316,7 +316,7 @@ void LaunchLink(string address) // NOTE: In this case, the Excel process does not quit after closing Excel... } } - + Point GetMouseLocation(IntPtr lParam) { int x = (short)(unchecked((int)(long)lParam) & 0xFFFF); @@ -341,10 +341,10 @@ protected override void OnPaint(PaintEventArgs e) int layoutLeft = ClientRectangle.Location.X + leftPadding; int layoutTop = ClientRectangle.Location.Y; - var textFormatFlags = TextFormatFlags.Left | - TextFormatFlags.Top | - TextFormatFlags.NoPadding | - TextFormatFlags.SingleLine | + var textFormatFlags = TextFormatFlags.Left | + TextFormatFlags.Top | + TextFormatFlags.NoPadding | + TextFormatFlags.SingleLine | TextFormatFlags.ExternalLeading; List lineWidths = new List(); @@ -476,7 +476,7 @@ void UpdateLocation(int width, int height) //var thisScreen = Screen.FromControl(this); //var screenChange = ownerScreen != thisScreen; - + //Debug.Print($"TOOLTIP SCREEN: {thisScreen.DeviceName}"); //Debug.Print($"OWNER SCREEN: {ownerScreen.DeviceName}"); @@ -499,7 +499,7 @@ void UpdateLocation(int width, int height) bool tipFits = workingArea.Contains(new Rectangle(_currentLeft, _currentTop + _topOffset, width, height)); if (!tipFits && (_currentLeft == _showLeft && _currentTop == _showTop)) { - // It doesn't fit and it's still where we initially tried to show it + // It doesn't fit and it's still where we initially tried to show it // (so it probably hasn't been moved). // Or the screen we are on is not the right one // so we recalc position (or at least clamp) @@ -552,15 +552,15 @@ protected override CreateParams CreateParams void InitializeComponent() { this.components = new System.ComponentModel.Container(); - this.tipDna = new System.Windows.Forms.ToolTip(this.components); + // this.tipDna = new System.Windows.Forms.ToolTip(this.components); this.SuspendLayout(); - // + // // tipDna - // - this.tipDna.ShowAlways = true; - // + // + // this.tipDna.ShowAlways = true; + // // ToolTipForm - // + // this.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink; this.BackColor = System.Drawing.Color.White; this.ClientSize = new System.Drawing.Size(114, 20); @@ -570,7 +570,7 @@ void InitializeComponent() this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.None; this.Name = "ToolTipForm"; this.ShowInTaskbar = false; - this.tipDna.SetToolTip(this, "IntelliSense by Excel-DNA"); + // this.tipDna.SetToolTip(this, "IntelliSense by Excel-DNA"); this.ResumeLayout(false); } @@ -579,19 +579,5 @@ internal static void SetStandardFont(string standardFontName, double standardFon { s_standardFont = new Font(standardFontName, (float)standardFontSize); } - - class Win32Window : IWin32Window - { - public IntPtr Handle - { - get; - private set; - } - - public Win32Window(IntPtr handle) - { - Handle = handle; - } - } } } diff --git a/Source/ExcelDna.IntelliSense/UIMonitor/FormulaEditWatcher.cs b/Source/ExcelDna.IntelliSense/UIMonitor/FormulaEditWatcher.cs index fa715a2..78e3d0e 100644 --- a/Source/ExcelDna.IntelliSense/UIMonitor/FormulaEditWatcher.cs +++ b/Source/ExcelDna.IntelliSense/UIMonitor/FormulaEditWatcher.cs @@ -1,8 +1,8 @@ -using ExcelDna.IntelliSense.Util; -using System; +using System; using System.Diagnostics; using System.Threading; using System.Windows; +using ExcelDna.IntelliSense.Util; namespace ExcelDna.IntelliSense { @@ -15,7 +15,43 @@ enum FormulaEditFocus { None = 0, FormulaBar = 1, - InCellEdit = 2 + InCellEdit = 2, + } + + public static readonly FormulaData EmptyFormula = new() { Text = string.Empty }; + + public struct FormulaData : IEquatable + { + public string Text { get; set; } + public int CaretStartPosition { get; set; } + public int CaretEndPosition { get; set; } + + public bool Equals(FormulaData other) + { + return string.Equals(Text, other.Text, StringComparison.Ordinal) + && CaretStartPosition == other.CaretStartPosition + && CaretEndPosition == other.CaretEndPosition; + } + + public override bool Equals(object obj) + { + return obj is FormulaData other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(Text, CaretStartPosition, CaretEndPosition); + } + + public static bool operator ==(FormulaData left, FormulaData right) + { + return left.Equals(right); + } + + public static bool operator !=(FormulaData left, FormulaData right) + { + return !left.Equals(right); + } } public enum StateChangeType @@ -45,9 +81,9 @@ public StateChangeEventArgs(StateChangeType? stateChangeType = null) public event EventHandler StateChanged; public bool IsEditingFormula { get; set; } - public string CurrentPrefix { get; set; } // Null if not editing - // We don't really care whether it is the formula bar or in-cell, - // we just need to get the right window handle + public FormulaData? CurrentPrefix { get; set; } // Null if not editing + // We don't really care whether it is the formula bar or in-cell, + // we just need to get the right window handle public Rect EditWindowBounds { get; set; } public IntPtr FormulaEditWindow @@ -178,7 +214,7 @@ void _windowWatcher_InCellEditWindowChanged(object sender, WindowWatcher.WindowC } else if (e.ObjectId == WindowWatcher.WindowChangedEventArgs.ChangeObjectId.Caret) { - // We expect this on every text change + // We expect this on every text change // NOTE: Not anymore after some Excel / Windows update Debug.Print($"-#-#-#- Text Changed ... "); _updateEditStateAfterTimeout.Signal(); @@ -322,9 +358,9 @@ void UpdateEditState(bool moveOnly = false) { // Switches to our Main UI thread, updates current state and raises StateChanged event _syncContextMain.Post(_ => - { - UpdateEditStateImpl(moveOnly); - }, null); + { + UpdateEditStateImpl(moveOnly); + }, null); } void UpdateEditStateImpl(bool moveOnly = false) diff --git a/Source/ExcelDna.IntelliSense/UIMonitor/FormulaParser.cs b/Source/ExcelDna.IntelliSense/UIMonitor/FormulaParser.cs index 4e677fd..6fbfe08 100644 --- a/Source/ExcelDna.IntelliSense/UIMonitor/FormulaParser.cs +++ b/Source/ExcelDna.IntelliSense/UIMonitor/FormulaParser.cs @@ -9,17 +9,48 @@ static class FormulaParser { // Set from IntelliSenseDisplay.Initialize public static char ListSeparator = ','; + public static MatchEvaluator Replacer = (m) => new string('_', m.Value.Length); + // TODO: What's the Unicode situation? public static string forbiddenNameCharacters = @"\ /\-:;!@\#\$%\^&\*\(\)\+=,<>\[\]{}|'\"""; - public static string functionNameRegex = "[" + forbiddenNameCharacters + "](?[^" + forbiddenNameCharacters + "]*)$"; + public static string functionNameRegex = + "[" + forbiddenNameCharacters + "](?[^" + forbiddenNameCharacters + "]*)$"; public static string functionNameGroupName = "functionName"; - internal static bool TryGetFormulaInfo(string formulaPrefix, out string functionName, out int currentArgIndex) + internal static bool TryGetFormulaInfo( + FormulaEditWatcher.FormulaData formulaData, + out string functionName, + out int currentArgIndex, + out string currentArgValue + ) { - Debug.Assert(formulaPrefix != null); + functionName = null; + currentArgValue = null; + currentArgIndex = -1; + //Debug.Assert(formulaPrefix != null); + if (formulaData.Text.Length < 2 || formulaData.CaretEndPosition < 2) + { + return false; + } + var originalFormulaPrefix = formulaData.Text; + var formulaPrefix = formulaData.Text; + var closedFunctionOffset = 0; + + // if (formulaPrefix.Length == formulaData.CaretEndPosition) + // { + // closedFunctionOffset += 1; + // } + // if formula is closed open so that the following logic works + if (formulaPrefix.Last() == ')' && formulaPrefix.Count(c => c == '(') == formulaPrefix.Count(c => c == ')')) + { + if (formulaData.Text.Length == formulaData.CaretEndPosition) + return false; + formulaPrefix = formulaPrefix[..^1]; + closedFunctionOffset += 1; + } // Hide the strings, in order to ignore the commas, parenthesis and curly brackets which might be inside. - // Ex: =SomeFunction("a", "b,c(d,{e,f, + // Ex: =SomeFunction("a", "b,c(d,{e,f, // In the example above, the function is SomeFunction, and the index is 2 // If editing a string, then close the double-quotes @@ -27,23 +58,27 @@ internal static bool TryGetFormulaInfo(string formulaPrefix, out string function { formulaPrefix = string.Concat(formulaPrefix, '\"'); } - + // Remove the strings. // Note: in Excel, in order to put a double-quotes in a string, one has to double the double-quotes. - // For instance, "a""b" for a"b. Since we only want to hide the strings in order to count the commas, + // For instance, "a""b" for a"b. Since we only want to hide the strings in order to count the commas, // the regex below still applies. - formulaPrefix = Regex.Replace(formulaPrefix, "(\"[^\"]*\")", string.Empty); + formulaPrefix = Regex.Replace(formulaPrefix, "(\"[^\"]*\")", Replacer); // Remove sub-formulae - formulaPrefix = Regex.Replace(formulaPrefix, "(\\([^\\(\\)]*\\))| ", string.Empty); + formulaPrefix = Regex.Replace(formulaPrefix, "(\\([^\\(\\)]*\\))| ", Replacer); while (Regex.IsMatch(formulaPrefix, "\\([^\\(\\)]*\\)")) { - formulaPrefix = Regex.Replace(formulaPrefix, "\\([^\\(\\)]*\\)", string.Empty); + formulaPrefix = Regex.Replace(formulaPrefix, "\\([^\\(\\)]*\\)", Replacer); } // Find the function name and the argument index - int lastOpeningParenthesis = formulaPrefix.LastIndexOf("(", formulaPrefix.Length - 1, StringComparison.Ordinal); + int lastOpeningParenthesis = formulaPrefix.LastIndexOf( + "(", + formulaData.CaretEndPosition - 1, + StringComparison.Ordinal + ); if (lastOpeningParenthesis > -1) { @@ -52,7 +87,11 @@ internal static bool TryGetFormulaInfo(string formulaPrefix, out string function { functionName = match.Groups[functionNameGroupName].Value; - string argumentsPart = formulaPrefix.Substring(lastOpeningParenthesis, formulaPrefix.Length - lastOpeningParenthesis); + string argumentsPart = formulaPrefix.Substring( + lastOpeningParenthesis, + formulaData.CaretEndPosition - lastOpeningParenthesis + ); + string originalArgumentsPart = argumentsPart; // Hide array formulae // Ex: =SomeFunction("a", {"a", "b", "c" @@ -66,15 +105,33 @@ internal static bool TryGetFormulaInfo(string formulaPrefix, out string function } // Remove the arrays. - argumentsPart = Regex.Replace(argumentsPart, "(\\{[^\\}]*\\})", string.Empty); + argumentsPart = Regex.Replace(argumentsPart, "(\\{[^\\}]*\\})", Replacer); currentArgIndex = argumentsPart.Count(c => c == ListSeparator); + if (currentArgIndex == 0) + { + currentArgValue = originalFormulaPrefix.Substring(lastOpeningParenthesis + 1); + } + else + { + var lastIndexOfComma = argumentsPart.LastIndexOf(ListSeparator); + var nextCommaAfterLast = originalFormulaPrefix.IndexOf( + ListSeparator, + lastIndexOfComma + lastOpeningParenthesis + 1 + ); + if (nextCommaAfterLast == -1) + { + nextCommaAfterLast = originalFormulaPrefix.Length - closedFunctionOffset; + } + currentArgValue = originalFormulaPrefix[ + (lastIndexOfComma + lastOpeningParenthesis + 1)..nextCommaAfterLast + ]; + } + return true; } } - functionName = null; - currentArgIndex = -1; return false; } diff --git a/Source/ExcelDna.IntelliSense/UIMonitor/KeyboardEventHook.cs b/Source/ExcelDna.IntelliSense/UIMonitor/KeyboardEventHook.cs new file mode 100644 index 0000000..7ebc2ea --- /dev/null +++ b/Source/ExcelDna.IntelliSense/UIMonitor/KeyboardEventHook.cs @@ -0,0 +1,192 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading; +using System.Windows.Forms; + +namespace ExcelDna.IntelliSense +{ + class KeyboardEventHook : IDisposable + { + public class KeyboardEventArgs : EventArgs + { + public readonly Keys Key; + public readonly KeyboardLParam LParam; + + public KeyboardEventArgs(Keys key, IntPtr lParam) + { + Key = key; + LParam = new KeyboardLParam() { Raw = lParam }; + } + } + + public struct KeyboardLParam + { + public IntPtr Raw; + + public readonly long Value => Raw.ToInt64(); + + public readonly ushort RepeatCount => (ushort)(Value & 0xFFFF); + public readonly byte ScanCode => (byte)((Value >> 16) & 0xFF); + public readonly bool IsExtended => (Value & (1 << 24)) != 0; + public readonly bool ContextCodeAlt => (Value & (1 << 29)) != 0; + public readonly bool WasDown => (Value & (1 << 30)) != 0; // 1 = was down before this message + public readonly bool IsReleased => (Value & (1 << 31)) != 0; // 1 = key released (keyup) + } + + internal delegate IntPtr KeyboardProc(int nCode, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll", SetLastError = true)] + static extern IntPtr SetWindowsHookEx(WH idHook, KeyboardProc lpfn, IntPtr hMod, uint dwThreadId); + + [DllImport("user32.dll", SetLastError = true)] + static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam); + + [DllImport("kernel32.dll")] + static extern uint GetCurrentThreadId(); + + [DllImport("user32.dll", SetLastError = true)] + internal static extern bool UnhookWindowsHookEx(IntPtr hHook); + + internal enum WH : int + { + KEYBOARD = 2, + KEYBOARD_LL = 13, + } + + public event EventHandler KeyboardEventReceived; + + public delegate bool ShouldSwallowDelegate(); + public event ShouldSwallowDelegate ShouldSwallow; + + /* readonly */ + IntPtr _hKeyboardEventHook; + + // readonly SynchronizationContext _syncContextAuto; + readonly SynchronizationContext _syncContextMain; + readonly KeyboardProc _handleKeyboardEventDelegate; // Ensures delegate that we pass to SetWindowsHookEx is not GC'd + + // Can be called on any thread, but installed by calling into the main thread, and will only start receiving events then + public KeyboardEventHook(SynchronizationContext syncContextMain) + { + // _syncContextAuto = syncContextAuto ?? throw new ArgumentNullException(nameof(syncContextAuto)); + _syncContextMain = syncContextMain ?? throw new ArgumentNullException(nameof(syncContextMain)); + _handleKeyboardEventDelegate = HandleKeyboardEvent; + syncContextMain.Post(InstallKeyboardEventHook, null); + } + + // Must run on the main Excel thread (or another thread where Windows messages are pumped) + void InstallKeyboardEventHook(object _) + { + uint tid = GetCurrentThreadId(); + _hKeyboardEventHook = SetWindowsHookEx(WH.KEYBOARD, _handleKeyboardEventDelegate, IntPtr.Zero, tid); + if (_hKeyboardEventHook == IntPtr.Zero) + { + Logger.WinEvents.Error("SetWindowsHookEx failed"); + // Is SetLastError used? - SetWindowsHookEx documentation does not indicate so + throw new Win32Exception("SetWindowsHookEx failed"); + } + Logger.WinEvents.Info($"SetWindowsHookEx success on thread {Thread.CurrentThread.ManagedThreadId}"); + } + + // This runs on the Excel main thread (usually, not always) - get off quickly + IntPtr HandleKeyboardEvent(int nCode, IntPtr wParam, IntPtr lParam) + { + Debug.Print( + $"++++++++++++++ HandleKeyboardEvent Received: {wParam} on thread {Thread.CurrentThread.ManagedThreadId} +++++++++++++++++++++++++++" + ); + try + { + var vkCode = wParam.ToInt32(); // wParam is the virtual-key code here + Keys key = (Keys)vkCode; + var handlers = ShouldSwallow?.GetInvocationList(); + if (nCode < 0 || !IsSupportedKeyboardEvent(key) || handlers == null) + { + return CallNextHookEx(_hKeyboardEventHook, nCode, wParam, lParam); + } + + bool shouldSwallow = false; + foreach (var d in handlers) + { + var handler = (ShouldSwallowDelegate)d; + + // Invoke directly + shouldSwallow |= handler.Invoke(); + } + + if (!shouldSwallow) + { + return CallNextHookEx(_hKeyboardEventHook, nCode, wParam, lParam); + } + + // CONSIDER: We might add some filtering here... maybe only interested in some of the window / event combinations + _syncContextMain.Post(OnKeyboardEventReceived, new KeyboardEventArgs(key, lParam)); + return (IntPtr)1; // swallow event + } + catch (Exception ex) + { + Logger.WinEvents.Warn($"HandleKeyboardEvent Exception {ex}"); + return (IntPtr)0; + } + } + + void UninstallKeyboardEventHook(object _) + { + if (_hKeyboardEventHook == IntPtr.Zero) + { + Logger.WinEvents.Warn( + $"UninstallKeyboardEvent unexpectedly called with no hook installed - thread {Thread.CurrentThread.ManagedThreadId}" + ); + return; + } + + try + { + Logger.WinEvents.Info( + $"UninstallKeyboardEvent called on thread {Thread.CurrentThread.ManagedThreadId}" + ); + bool result = UnhookWindowsHookEx(_hKeyboardEventHook); + if (!result) + { + // GetLastError? + Logger.WinEvents.Info($"UninstallKeyboardEvent failed"); + } + else + { + Logger.WinEvents.Info("UninstallKeyboardEvent success"); + } + } + catch (Exception ex) + { + Logger.WinEvents.Warn($"UninstallKeyboardEvent Exception {ex}"); + } + finally + { + _hKeyboardEventHook = IntPtr.Zero; + } + } + + bool IsSupportedKeyboardEvent(Keys key) + { + return key is Keys.Down or Keys.Up or Keys.Escape or Keys.Tab; + } + + void OnKeyboardEventReceived(object kbEventArgsObj) + { + var kbEventArgs = (KeyboardEventArgs)kbEventArgsObj; + + KeyboardEventReceived?.Invoke(this, kbEventArgs); + } + + #region IDisposable Support + // Must be called on the main thread + public void Dispose() + { + //Debug.Assert(Thread.CurrentThread.ManagedThreadId == 1); + Logger.WinEvents.Info($"KeyboardEventHook Dispose on thread {Thread.CurrentThread.ManagedThreadId}"); + UninstallKeyboardEventHook(null); + } + #endregion + } +} diff --git a/Source/ExcelDna.IntelliSense/UIMonitor/UIMonitor.cs b/Source/ExcelDna.IntelliSense/UIMonitor/UIMonitor.cs index defb2ae..b64d855 100644 --- a/Source/ExcelDna.IntelliSense/UIMonitor/UIMonitor.cs +++ b/Source/ExcelDna.IntelliSense/UIMonitor/UIMonitor.cs @@ -78,7 +78,7 @@ void _popupListWatcher_SelectedItemChanged(object sender, EventArgs args) if (CurrentState is UIState.FunctionList && _popupListWatcher.IsVisible) { - var newState = ((UIState.FunctionList)CurrentState).WithSelectedItem(_popupListWatcher.SelectedItemText, + var newState = ((UIState.FunctionList)CurrentState).WithSelectedItem(_popupListWatcher.SelectedItemText, _popupListWatcher.SelectedItemBounds, _popupListWatcher.ListBounds); OnStateChanged(newState); @@ -172,7 +172,7 @@ UIState ReadCurrentState() SelectedItemBounds = _popupListWatcher.SelectedItemBounds, FunctionListBounds = _popupListWatcher.ListBounds, EditWindowBounds = _formulaEditWatcher.EditWindowBounds, - FormulaPrefix = _formulaEditWatcher.CurrentPrefix ?? "", + FormulaPrefix = _formulaEditWatcher.CurrentPrefix ?? FormulaEditWatcher.EmptyFormula, ExcelToolTipWindow = _lastExcelToolTipShown // We also keep track here, since we'll by inferring the UIState change list using this too }; } @@ -182,7 +182,7 @@ UIState ReadCurrentState() { FormulaEditWindow = _formulaEditWatcher.FormulaEditWindow, EditWindowBounds = _formulaEditWatcher.EditWindowBounds, - FormulaPrefix = _formulaEditWatcher.CurrentPrefix ?? "", + FormulaPrefix = _formulaEditWatcher.CurrentPrefix ?? FormulaEditWatcher.EmptyFormula, ExcelToolTipWindow = _lastExcelToolTipShown }; } @@ -248,7 +248,7 @@ void OnStateChanged(UIState newStateOrNull = null) // updates.Add(firstUpdate); // do // { - // var update = updateEnumerator.Current; + // var update = updateEnumerator.Current; // if (StateUpdateFilter(update)) // updates.Add(update); // } while (updateEnumerator.MoveNext()); @@ -312,4 +312,3 @@ public void Dispose() } } } - diff --git a/Source/ExcelDna.IntelliSense/UIMonitor/UIState.cs b/Source/ExcelDna.IntelliSense/UIMonitor/UIState.cs index f34e752..341a15c 100644 --- a/Source/ExcelDna.IntelliSense/UIMonitor/UIState.cs +++ b/Source/ExcelDna.IntelliSense/UIMonitor/UIState.cs @@ -9,7 +9,7 @@ namespace ExcelDna.IntelliSense // These are immutable representations of the state (reflecting only our interests) // We make a fresh a simplified state representation, so that we can make a matching state update representation. // - // One shortcoming of our representation is that we don't track a second selection list and matching ExcelToolTip + // One shortcoming of our representation is that we don't track a second selection list and matching ExcelToolTip // that might pop up for an argument, e.g. VLOOKUP's TRUE/FALSE. abstract class UIState { @@ -18,7 +18,7 @@ public class Ready : UIState { } public class FormulaEdit : UIState { public IntPtr FormulaEditWindow; // Window where text entry focus is - either the in-cell edit window, or the formula bar - public string FormulaPrefix; // Never null + public FormulaEditWatcher.FormulaData FormulaPrefix; public Rect EditWindowBounds; public IntPtr ExcelToolTipWindow; // ExcelToolTipWindow is Zero or is _some_ visible tooltip (either for from the list or the function) @@ -33,12 +33,12 @@ public virtual FormulaEdit WithFormulaEditWindow(IntPtr newFormulaEditWindow) }; } - public virtual FormulaEdit WithFormulaPrefix(string newFormulaPrefix) + public virtual FormulaEdit WithFormulaPrefix(FormulaEditWatcher.FormulaData? newFormulaPrefix) { return new FormulaEdit { FormulaEditWindow = this.FormulaEditWindow, - FormulaPrefix = newFormulaPrefix ?? "", + FormulaPrefix = newFormulaPrefix ?? FormulaEditWatcher.EmptyFormula, EditWindowBounds = this.EditWindowBounds, ExcelToolTipWindow = this.ExcelToolTipWindow }; @@ -107,12 +107,12 @@ public FunctionList WithFunctionListWindow(IntPtr newFunctionListWindow) }; } - public override FormulaEdit WithFormulaPrefix(string newFormulaPrefix) + public override FormulaEdit WithFormulaPrefix(FormulaEditWatcher.FormulaData? newFormulaPrefix) { return new FunctionList { FormulaEditWindow = this.FormulaEditWindow, - FormulaPrefix = newFormulaPrefix ?? "", + FormulaPrefix = newFormulaPrefix ?? FormulaEditWatcher.EmptyFormula, EditWindowBounds = this.EditWindowBounds, ExcelToolTipWindow = this.ExcelToolTipWindow, @@ -173,13 +173,13 @@ public virtual FunctionList WithSelectedItem(string selectedItemText, Rect selec internal FormulaEdit AsFormulaEdit() { - return new FormulaEdit - { - FormulaEditWindow = FormulaEditWindow, - FormulaPrefix = FormulaPrefix, - EditWindowBounds = EditWindowBounds, - ExcelToolTipWindow = ExcelToolTipWindow, - }; + return new FormulaEdit + { + FormulaEditWindow = FormulaEditWindow, + FormulaPrefix = FormulaPrefix, + EditWindowBounds = EditWindowBounds, + ExcelToolTipWindow = ExcelToolTipWindow, + }; } } @@ -191,21 +191,21 @@ public class SelectDataSource : UIState public override string ToString() { - #if DEBUG - return $"{GetType().Name}{((this is Ready) ? "" : "\r\n")}{string.Join("\r\n", GetType().GetFields().Select(fld => $"\t{fld.Name}: {fld.GetValue(this)}"))}"; - #else - return base.ToString(); - #endif +#if DEBUG + return $"{GetType().Name}{((this is Ready) ? "" : "\r\n")}{string.Join("\r\n", GetType().GetFields().Select(fld => $"\t{fld.Name}: {fld.GetValue(this)}"))}"; +#else + return base.ToString(); +#endif } // TODO: Figure out what to do with this public string LogString() { - #if DEBUG - return $"{GetType().Name}{((this is Ready) ? "" : "\t")}{string.Join("\t", GetType().GetFields().Select(fld => $"\t{fld.Name}: {fld.GetValue(this)}"))}"; - #else - return ToString(); - #endif +#if DEBUG + return $"{GetType().Name}{((this is Ready) ? "" : "\t")}{string.Join("\t", GetType().GetFields().Select(fld => $"\t{fld.Name}: {fld.GetValue(this)}"))}"; +#else + return ToString(); +#endif } // This is the universal update check @@ -405,22 +405,22 @@ class UIStateUpdate : EventArgs public enum UpdateType { FormulaEditStart, - // These three updates can happen while FunctionList is shown + // These three updates can happen while FunctionList is shown FormulaEditMove, // Includes moving between in-cell editing and the formula text box. - FormulaEditWindowChange, // Includes moving between in-cell editing and the formula text box. - FormulaEditTextChange, - FormulaEditExcelToolTipChange, + FormulaEditWindowChange, // Includes moving between in-cell editing and the formula text box. + FormulaEditTextChange, + FormulaEditExcelToolTipChange, - FunctionListShow, - FunctionListMove, - FunctionListSelectedItemChange, - FunctionListWindowChange, - FunctionListHide, + FunctionListShow, + FunctionListMove, + FunctionListSelectedItemChange, + FunctionListWindowChange, + FunctionListHide, FormulaEditEnd, SelectDataSourceShow, - SelectDataSourceWindowChange, + SelectDataSourceWindowChange, SelectDataSourceHide } public UIState OldState { get; } diff --git a/Source/ExcelDna.IntelliSense/UIMonitor/XlCallHelper.cs b/Source/ExcelDna.IntelliSense/UIMonitor/XlCallHelper.cs index 2d96bcc..b484d52 100644 --- a/Source/ExcelDna.IntelliSense/UIMonitor/XlCallHelper.cs +++ b/Source/ExcelDna.IntelliSense/UIMonitor/XlCallHelper.cs @@ -12,13 +12,13 @@ class XlCallHelper // Maybe helps with debugging... static bool _shutdownStarted = false; public static void ShutdownStarted() - { + { _shutdownStarted = true; } // This call must be made on the main thread // Returns null if not in edit mode - public static string GetFormulaEditPrefix() + public static FormulaEditWatcher.FormulaData? GetFormulaEditPrefix() { if (_shutdownStarted) return null; @@ -47,8 +47,14 @@ public static string GetFormulaEditPrefix() Logger.WindowWatcher.Verbose("LPenHelper Status: PointMode: {0}, Formula: {1}, First: {2}, Last: {3}, Caret: {4}", fmlaInfo.wPointMode, Marshal.PtrToStringUni(fmlaInfo.lpch, fmlaInfo.cch), fmlaInfo.ichFirst, fmlaInfo.ichLast, fmlaInfo.ichCaret); - var prefixLen = Math.Min(Math.Max(fmlaInfo.ichCaret, fmlaInfo.ichLast), fmlaInfo.cch); // I've never seen ichLast > cch !? - return Marshal.PtrToStringUni(fmlaInfo.lpch, prefixLen); + var prefixLen = Math.Max(Math.Max(fmlaInfo.ichCaret, fmlaInfo.ichLast), fmlaInfo.cch); // I've never seen ichLast > cch !? + var formula = Marshal.PtrToStringUni(fmlaInfo.lpch, prefixLen); + return new FormulaEditWatcher.FormulaData() + { + Text = formula, + CaretStartPosition = fmlaInfo.ichCaret, + CaretEndPosition = fmlaInfo.ichLast, + }; } catch (Exception ex) { diff --git a/Source/ExcelDna.IntelliSense/Win32Helper.cs b/Source/ExcelDna.IntelliSense/Win32Helper.cs index fe7aa2d..8a80116 100644 --- a/Source/ExcelDna.IntelliSense/Win32Helper.cs +++ b/Source/ExcelDna.IntelliSense/Win32Helper.cs @@ -107,11 +107,11 @@ struct RECT enum GetAncestorFlags { - // Retrieves the parent window. This does not include the owner, as it does with the GetParent function. + // Retrieves the parent window. This does not include the owner, as it does with the GetParent function. GetParent = 1, // Retrieves the root window by walking the chain of parent windows. GetRoot = 2, - // Retrieves the owned root window by walking the chain of parent and owner windows returned by GetParent. + // Retrieves the owned root window by walking the chain of parent and owner windows returned by GetParent. GetRootOwner = 3 } @@ -126,6 +126,39 @@ enum GetAncestorFlags [DllImport("user32.dll")] public static extern bool ReleaseCapture(); + [DllImport("user32.dll")] + static extern bool ClientToScreen(IntPtr hWnd, ref Point lpPoint); + + public static bool TryGetCaretScreenPosition(IntPtr hWnd, out Point screenPoint) + { + screenPoint = Point.Empty; + + // Determine thread owning the given Excel window + uint tid = GetWindowThreadProcessId(hWnd, out var _); + + GUITHREADINFO info = new GUITHREADINFO(); + info.cbSize = Marshal.SizeOf(typeof(GUITHREADINFO)); + + if (!GetGUIThreadInfo(tid, ref info)) + return false; + + // hwndCaret will be non-null only when caret exists + if (info.hwndCaret == IntPtr.Zero) + return false; + + // Caret rect relative to the caret window + var rc = info.rcCaret; + + // Top-left of caret rectangle + Point pt = new Point(rc.Left, rc.Bottom); + + // Convert to screen coordinates + if (!ClientToScreen(info.hwndCaret, ref pt)) + return false; + + screenPoint = pt; + return true; + } // Returns the WindowHandle of the focused window, if that window is in our process. public static IntPtr GetFocusedWindowHandle() @@ -167,7 +200,7 @@ public static System.Drawing.Point GetClientCursorPos(IntPtr hWnd) // We use System.Windows.Rect to be consistent with the UIAutomation we used initially. // Returns Rect.Empty if the Win32 call fails (Window is not visible?) - // Returns the window bounds in + // Returns the window bounds in public static System.Windows.Rect GetWindowBounds(IntPtr hWnd) { RECT rect; // This struct layout is like Win32 RECT (not like System.Drawing.Rectangle) diff --git a/Source/ExcelDna.IntelliSense/Win32Window.cs b/Source/ExcelDna.IntelliSense/Win32Window.cs new file mode 100644 index 0000000..7d04b0b --- /dev/null +++ b/Source/ExcelDna.IntelliSense/Win32Window.cs @@ -0,0 +1,14 @@ +using System; +using System.Windows.Forms; + +namespace ExcelDna.IntelliSense; + +internal class Win32Window : IWin32Window +{ + public IntPtr Handle { get; private set; } + + public Win32Window(IntPtr handle) + { + Handle = handle; + } +}