WPF custom task pane woes

499 views
Skip to first unread message

Chel

unread,
Apr 18, 2016, 6:43:33 PM4/18/16
to Excel-DNA
I'm having some trouble with WPF custom task panes in Excel-DNA. To start, here's a simple C# implementation of a task pane wired up to the ribbon. Constructive criticism welcome, etc.

WpfTaskPaneTest.dna

<DnaLibrary Name="WPFTaskPaneTest" RuntimeVersion="v4.0" Language="CS">
   
<ExternalLibrary Path="WpfTaskPaneTest.dll" ExplicitExports="true" />
   
<CustomUI>
       
<customUI xmlns="http://schemas.microsoft.com/office/2006/01/customui"
           
onLoad="OnLoadCust">
           
<ribbon>
               
<tabs>
                   
<tab id="WPFTP" label="WPFTPT">
                       
<group id="WPFTPg" label="WPFTPT">
                           
<toggleButton id="WPFTPb" label="Show Task Pane"
                               
getPressed="GetPaneVisible" onAction="TogglePane" />
                       
</group>
                   
</tab>
               
</tabs>
           
</ribbon>
       
</customUI>
   
</CustomUI>
</DnaLibrary>

WpfTaskPaneTest.cs

using ExcelDna.Integration;
using ExcelDna.Integration.CustomUI;
using ExcelDna.Integration.Extensibility;
using System;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Forms.Integration;
using Forms = System.Windows.Forms;
using WpfApp = System.Windows.Application;
using XamlReader = System.Windows.Markup.XamlReader;

namespace WPFTaskPaneTest {
   
[ComVisible(true)]
   
public sealed class PaneHost : Forms.UserControl {
       
public ElementHost Host;

       
public PaneHost() {
           
SuspendLayout();

           
Host = new ElementHost() {
               
BackColor = System.Drawing.Color.Transparent,
               
BackColorTransparent = true,
               
Dock = Forms.DockStyle.Fill,
               
Location = new System.Drawing.Point(0, 0),
               
Name = "Host",
               
Size = new System.Drawing.Size(180, 600),
               
TabIndex = 0,
               
TabStop = false,
               
Text = "Host",
               
Child = null
           
};

           
AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
           
AutoScaleMode = Forms.AutoScaleMode.Font;
           
AutoScroll = true;
           
Controls.Add(Host);
           
Margin = new Forms.Padding(0);
           
Name = "PaneHost";
           
Size = new System.Drawing.Size(180, 600);

           
ResumeLayout(false);
       
}
   
}

   
[ComVisible(true)]
   
public sealed class RibbonUi : ExcelRibbon {
       
private IRibbonUI _uiCtl;

       
public override void OnConnection(object application, ext_ConnectMode connectMode,
           
object addInInst, ref Array custom) {
           
new WpfApp { ShutdownMode = ShutdownMode.OnExplicitShutdown };
           
base.OnConnection(application, connectMode, addInInst, ref custom);
       
}

       
public override void OnDisconnection(ext_DisconnectMode removeMode, ref Array custom) {
           
TaskPaneMgr.Shutdown();
           
WpfApp.Current.Shutdown();
            GC
.Collect();
            GC
.WaitForPendingFinalizers();
           
base.OnDisconnection(removeMode, ref custom);
       
}


       
public bool GetPaneVisible(IRibbonControl ctl) => TaskPaneMgr.PaneVisible;

       
public void OnLoadCust(IRibbonUI obj) {
            _uiCtl
= obj;
           
Globals.OnTaskPaneVisibilityChanged += () => _uiCtl?.InvalidateControl("WPFTPb");
            obj
.Invalidate();
       
}

       
public void TogglePane(IRibbonControl ctl, bool state) {
           
TaskPaneMgr.PaneVisible = state;
       
}
   
}

   
static class Globals {
       
public static event Action OnTaskPaneVisibilityChanged; // TaskPaneMgr cannot host its own event

       
public static void NotifyTaskPaneVisibilityChanged() => OnTaskPaneVisibilityChanged?.Invoke();
   
}

   
static class TaskPaneMgr {
       
static CustomTaskPane _ctp;
       
static IntPtr _ctpParentHwnd;
       
static int _paneWidth = 180;
       
static UIElement _wpfPane;

       
public static bool PaneVisible {
           
get { return _ctp != null && _ctpParentHwnd == ExcelDnaUtil.WindowHandle && _ctp.Visible; }
           
set {
               
if (value) {
                   
if (!PaneVisible) {
                       
EnsurePaneCreated();
                       
try {
                           
// hide the ElementHost while toggling function pane visibility,
                           
// to reduce flicker under Excel 2013/2016
                           
var eh = (ElementHost)((PaneHost)_ctp.ContentControl).Host;
                            eh
.Visible = false;
                            _ctp
.Visible = true;
                            eh
.Visible = true;
                       
} catch (COMException) {
                            _ctp
= null;
                           
EnsurePaneCreated();
                           
var eh = (ElementHost)((PaneHost)_ctp.ContentControl).Host;
                            eh
.Visible = false;
                            _ctp
.Visible = true;
                            eh
.Visible = true;
                       
}
                   
}
               
} else if (_ctp != null) {
                   
try {
                        _ctp
.Visible = false;
                   
} catch (COMException) { } // ignore 'The taskpane has been deleted...'
               
}
           
}
       
}

       
public static void Shutdown() {
           
try {
                _ctp
?.Delete();
           
} catch { }
       
}

       
// in actual code this will of course be replaced with a WPF UserControl class :)
       
static UIElement GetWpfTaskPane() => (UIElement)XamlReader.Parse(
@"<UserControl xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation'
             xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'>
    <DockPanel LastChildFill='false'>
        <ComboBox Margin='4'
                  HorizontalAlignment='Center'
                  DockPanel.Dock='Bottom'>
            <ComboBoxItem Content='Item 1' IsSelected='True' />
            <ComboBoxItem Content='Item 2' />
            <ComboBoxItem Content='Item 3' />
            <ComboBoxItem Content='Item 4' />
            <ComboBoxItem Content='Item 5' />
            <ComboBoxItem Content='Item 6' />
            <ComboBoxItem Content='Item 7' />
            <ComboBoxItem Content='Item 8' />
        </ComboBox>
    </DockPanel>
</UserControl>"
);

       
static void EnsurePaneCreated() {
           
if (_wpfPane == null) {
                _wpfPane
= GetWpfTaskPane();
           
}
           
if (_ctp == null || _ctpParentHwnd != ExcelDnaUtil.WindowHandle) {
               
if (_ctp != null) {
                   
// if we have a task pane belonging to an inactive window, delete it
                   
try {
                       
((PaneHost)_ctp.ContentControl).Host.Child = null;
                        _ctp
.Delete();
                   
} catch (COMException) { } // ignore 'The taskpane has been deleted...'
               
}
                _ctp
= CustomTaskPaneFactory.CreateCustomTaskPane(new PaneHost(), "Task Pane");
               
try {
                   
((PaneHost)_ctp.ContentControl).Host.Child = _wpfPane;
               
} catch (InvalidOperationException) {
                   
// happens if the pane is already marked as child to another object
                    _wpfPane
= GetWpfTaskPane();
                   
((PaneHost)_ctp.ContentControl).Host.Child = _wpfPane;
               
}
                _ctpParentHwnd
= ExcelDnaUtil.WindowHandle;
                _ctp
.Width = _paneWidth;
                _ctp
.VisibleStateChange += p => {
                    _paneWidth
= _ctp.Width;
                   
Globals.NotifyTaskPaneVisibilityChanged();
               
};
           
}
       
}
   
}
}

Everything works as expected when Excel is maximized, and the ComboBox opens upward. The trouble starts when you position the Excel window such that the ComboBox opens downward, then select Item 2. Instead of selecting that item, the click is stolen by Excel, and sent to the zoom control. And when you select any item outside the Excel window's boundaries, the ComboBox closes and nothing else happens. According to the Add-In Express people, you can solve this by putting the following into PaneHost:

const uint WS_CLIPCHILDREN = 0x02000000;
const uint WS_CLIPSIBLINGS = 0x04000000;
const uint WS_POPUP = 0x80000000;

Forms.CreateParams _createParams = new Forms.CreateParams();

protected override Forms.CreateParams CreateParams {
   
get {
        _createParams
= base.CreateParams;
       
unchecked {
           
// need to add WS_POPUP because otherwise, Excel steals mouse clicks from controls that are
           
// drawn outside the normal task pane boundaries - e.g., if a ComboBox drops down over the
           
// Excel zoom widget, clicking within the ComboBox would go to the zoom widget instead
            _createParams
.Style = (int)(WS_CLIPCHILDREN | WS_CLIPSIBLINGS | WS_POPUP);
       
}
       
return _createParams;
   
}
}

Now the ComboBox works, but if you hover over the worksheet and use the scrollwheel, that event gets stolen by the task pane. Clicking on the worksheet makes no difference to this. To get the proper scroll behavior back, you have to click on the taskbar, then back on Excel. Which is something I can do programmatically:

public static void BlinkFocus() {
   
// Set focus to the taskbar, then back to Excel. Ugly hack, do not use.
   
SetForegroundWindow(FindWindow("Shell_trayWnd", null));
   
SetForegroundWindow(ExcelDnaUtil.WindowHandle);
}

[DllImport("user32.dll", SetLastError = true)]
static extern IntPtr FindWindow(string lpClassName, string lpWindowName);

[DllImport("user32.dll", SetLastError = true)]
static extern bool SetForegroundWindow(IntPtr hWnd);

But I'd rather not have to resort to that, since at this point I'm adding hacks atop hacks. There has to be a better way. Has anyone else had to deal with this?

Chel

unread,
Apr 21, 2016, 8:02:28 PM4/21/16
to Excel-DNA
Thanks to a little help from Govert, I've made my solution a little bit more robust. The user can still steal focus from Excel by actually clicking on the background of the task pane, as in this example the WPF portion is only a tiny bit of the total area - but that isn't an issue with my real task panes as they all take up all the available space :) For reference, my full code follows:



       
protected override Forms.CreateParams CreateParams {
           
get {
               
var cp = base.CreateParams;
               
unchecked {
                    cp
.Style &= ~0x40000000; // remove WS_CHILD
                    cp
.Style |= (int)0x80000000;  // include WS_POPUP
               
}
               
return cp;


       
public static bool PaneHasFocus => PaneVisible
           
&& GetForegroundWindow() == (_ctp?.ContentControl as Forms.Control)?.Handle;


       
public static bool PaneVisible {
           
get { return _ctp != null && _ctpParentHwnd == ExcelDnaUtil.WindowHandle && _ctp.Visible; }
           
set {
               
if (value) {
                   
if (!PaneVisible) {
                       
EnsurePaneCreated();

                       
PaneHost ph;
                       
ElementHost eh;

                       
try {
                           
// hide the ElementHost while toggling function pane visibility,
                           
// to reduce flicker under Excel 2013/2016

                            ph
= (PaneHost)_ctp.ContentControl;
                            eh
= ph.Host;

                            eh
.Visible = false;
                            _ctp
.Visible = true;
                            eh
.Visible = true;
                       
} catch (COMException) {
                            _ctp
= null;
                           
EnsurePaneCreated();

                            ph
= (PaneHost)_ctp.ContentControl;
                            eh
= ph.Host;

                            eh
.Visible = false;
                            _ctp
.Visible = true;
                            eh
.Visible = true;
                       
}
                   
}
               
} else if (_ctp != null) {
                   
try {
                        _ctp
.Visible = false;
                   
} catch (COMException) { } // ignore 'The taskpane has been deleted...'
               
}
           
}
       
}

       
public static void Shutdown() {
           
try {
                _ctp
?.Delete();
           
} catch { }
       
}


       
static UIElement GetWpfTaskPane() {

           
// in actual code this will of course be replaced with a WPF UserControl class :)

           
var uie = (UIElement)XamlReader.Parse(
@"<UserControl xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation'
             xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'>
    <DockPanel LastChildFill='False'>

        <ComboBox Margin='4'
                  HorizontalAlignment='Center'
                  DockPanel.Dock='Bottom'>
            <ComboBoxItem Content='Item 1' IsSelected='True' />
            <ComboBoxItem Content='Item 2' />
            <ComboBoxItem Content='Item 3' />
            <ComboBoxItem Content='Item 4' />
            <ComboBoxItem Content='Item 5' />
            <ComboBoxItem Content='Item 6' />
            <ComboBoxItem Content='Item 7' />
            <ComboBoxItem Content='Item 8' />
        </ComboBox>
        <Border />
    </DockPanel>
</UserControl>"
);
            uie
.MouseLeave += (s, e) => {
               
// whenever the cursor leaves the task pane, make focus follow the cursor to the main Excel window
               
if (PaneHasFocus) {
                   
SetForegroundWindow(ExcelDnaUtil.WindowHandle);
               
}
           
};
           
return uie;

       
}

       
static void EnsurePaneCreated() {
           
if (_wpfPane == null) {
                _wpfPane
= GetWpfTaskPane();
           
}
           
if (_ctp == null || _ctpParentHwnd != ExcelDnaUtil.WindowHandle) {
               
if (_ctp != null) {
                   
// if we have a task pane belonging to an inactive window, delete it
                   
try {
                       
((PaneHost)_ctp.ContentControl).Host.Child = null;
                        _ctp
.Delete();
                   
} catch (COMException) { } // ignore 'The taskpane has been deleted...'
               
}
                _ctp
= CustomTaskPaneFactory.CreateCustomTaskPane(new PaneHost(), "Task Pane");
               
try {
                   
((PaneHost)_ctp.ContentControl).Host.Child = _wpfPane;
               
} catch (InvalidOperationException) {

                   
// if the pane is already marked as child to another object

                    _wpfPane
= GetWpfTaskPane();
                   
((PaneHost)_ctp.ContentControl).Host.Child = _wpfPane;
               
}
                _ctpParentHwnd
= ExcelDnaUtil.WindowHandle;
                _ctp
.Width = _paneWidth;
                _ctp
.VisibleStateChange += p => {
                    _paneWidth
= _ctp.Width;
                   
Globals.NotifyTaskPaneVisibilityChanged();
               
};
           
}
       
}


       
[DllImport("user32.dll")]
       
private static extern IntPtr GetForegroundWindow();
Reply all
Reply to author
Forward
0 new messages