sources/Google.Solutions.Terminal/Controls/VirtualTerminal.cs (732 lines of code) (raw):
//
// Copyright 2024 Google LLC
//
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//
using Google.Solutions.Common.Runtime;
using Google.Solutions.Common.Text;
using Google.Solutions.Common.Util;
using Google.Solutions.Mvvm.Controls;
using Google.Solutions.Mvvm.Interop;
using Google.Solutions.Mvvm.Theme;
using Google.Solutions.Platform.IO;
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Windows.Forms;
namespace Google.Solutions.Terminal.Controls
{
/// <summary>
/// A virtual terminal based on the Windows Terminal aka "Cascadia".
/// </summary>
public partial class VirtualTerminal : DpiAwareUserControl
{
private const string DefaultFontFamily = "Consolas";
private const float DefaultFontSize = 9.75f;
private const int MinimumFontSizeForScrolling = 6;
private const int MaximumFontSizeForScrolling = 36;
private const string Esc = "\u001b";
private TerminalSafeHandle? terminal = null;
private IntPtr terminalHwnd;
private SubclassCallback? terminalSubclass;
private PseudoTerminalSize dimensions;
private VirtualTerminalBinding? deviceBinding;
private readonly NativeMethods.WriteCallback writeCallback;
private readonly NativeMethods.ScrollCallback scrollCallback;
//
// NB. The Windows Terminal calls the caret "cursor", which can be
// confusing in a GUI environment where "cursor" typically refers to
// the mouse pointer.
//
// For consistency's sake, we use the term "caret" here.
//
public VirtualTerminal()
{
InitializeComponent();
//
// Adjust scrollbar withdt to match system settings.
//
this.scrollBar.Width = SystemInformation.VerticalScrollBarWidth;
this.scrollBar.Location = new Point(this.Width - this.scrollBar.Width, 0);
this.writeCallback = new NativeMethods.WriteCallback(OnUserInput);
this.scrollCallback = new NativeMethods.ScrollCallback(OnTerminalScrolled);
if (this.DesignMode)
{
return;
}
var blinkTime = NativeMethods.GetCaretBlinkTime();
if (blinkTime == uint.MaxValue)
{
//
// Caret does not blink.
//
}
else
{
this.caretBlinkTimer.Tick += (_, __) =>
{
if (this.terminal != null && this.CanUseTerminal)
{
NativeMethods.TerminalBlinkCursor(this.terminal);
}
};
this.caretBlinkTimer.Interval = (int)blinkTime;
this.caretBlinkTimer.Start();
}
}
/// <summary>
/// Sanitize text from clipboard before pasting.
/// </summary>
internal string SanitizeTextForPasting(string text)
{
//
// Replace pretty quotes bt ASCII quotes.
//
if (this.EnableTypographicQuoteConversion)
{
text = TypographicQuotes.ToAsciiQuotes(text);
}
//
// Sanitize the CRLFs here, because most applications (incl. bash)
// will otherwise interpret a CRLF as two line breaks.
//
text = text.Replace("\r\n", "\r");
//
// Use bracketing to ensure pasting multiple lines into
// a shell doesn't cause immediate execution.
//
if (this.EnableBracketedPaste)
{
text = "\u001b[200~" + text + "\u001b[201~";
}
return text;
}
/// <summary>
/// Create terminal handle. This triggers the native DLL to be loaded.
///
/// For unit testing, it can be necessary to call this method explicitly.
/// During normal operation, it's invoked in OnHandleCreated.
/// </summary>
internal void CreateTerminalHandle()
{
if (this.DesignMode)
{
return;
}
if (this.terminal != null || this.terminalSubclass != null)
{
throw new InvalidOperationException(
"Handle has been created already");
}
//
// Create terminal. This loads the native DLL if it's the first time.
//
var hr = NativeMethods.CreateTerminal(
this.Handle,
out this.terminalHwnd,
out this.terminal);
if (hr != 0)
{
throw VirtualTerminalException.FromHresult(
hr,
"Allocating a terminal failed");
}
NativeMethods.TerminalRegisterWriteCallback(
this.terminal,
this.writeCallback);
NativeMethods.TerminalRegisterScrollCallback(
this.terminal,
this.scrollCallback);
OnThemeChanged();
//
// Install a subclassing hook so that we can handle some of the
// terminal HWND's messages.
//
this.terminalSubclass = new SubclassCallback(
this.terminalHwnd,
this,
TerminalSubclassWndProc);
this.terminalSubclass.UnhandledException += (_, ex)
=> Application.OnThreadException(ex);
this.components.Add(this.terminalSubclass.ToComponent());
//
// Resize terminal so that it fills the entire control.
//
OnResize(EventArgs.Empty);
if (NativeMethods.GetFocus() == this.terminalSubclass.WindowHandle)
{
this.caretBlinkTimer.Start();
}
else
{
NativeMethods.TerminalSetCursorVisible(this.terminal, false);
}
}
/// <summary>
/// Check if it's safe to use the terminal control.
/// </summary>
private bool CanUseTerminal
{
get =>
!this.IsDisposed &&
//
// Don't touch the terminal in design mode because it might
// crash VS.
//
!this.DesignMode &&
//
// Don't touch the terminal if it's been closed already,
// or if it's already processed a WM_DESTROY message because
// that might cause an access violation.
//
this.terminal != null &&
!this.terminalWindowDestructionBegun;
}
//---------------------------------------------------------------------
// Publics.
//---------------------------------------------------------------------
/// <summary>
/// Clear the screen.
/// </summary>
public void Clear()
{
ReceiveOutput(Esc + "[2J");
}
/// <summary>
/// Get or set dimensions of terminal (in characters).
/// </summary>
public PseudoTerminalSize Dimensions
{
get => this.dimensions;
internal set
{
Debug.Assert(!this.InvokeRequired, "Must be called on GUI thread");
this.dimensions = value;
OnDimensionsChanged();
}
}
/// <summary>
/// Get or set the device to interact with.
/// </summary>
public IPseudoTerminal? Device
{
get => this.deviceBinding?.Device;
set
{
Debug.Assert(!this.InvokeRequired, "Must be called on GUI thread");
if (this.deviceBinding != null)
{
Clear();
this.deviceBinding.Dispose();
this.deviceBinding = null;
}
if (value != null)
{
this.deviceBinding = new VirtualTerminalBinding(
this,
value);
}
}
}
//---------------------------------------------------------------------
// VT binding.
//---------------------------------------------------------------------
/// <summary>
/// Receive data to display. The data can contain xterm control
/// characters.
/// </summary>
internal void ReceiveOutput(string data)
{
if (this.InvokeRequired)
{
try
{
Invoke(((Action)(() => OnOutputReceived(data))));
}
catch (InvalidAsynchronousStateException) when (this.IsDisposed)
{
//
// We hit a (unavoidable) race condition where the
// window was being disposed during the call. We can
// just ignore that.
//
}
}
else
{
OnOutputReceived(data);
}
}
/// <summary>
/// Process error received from device.
/// </summary>
internal void ReceiveError(Exception e)
{
if (this.InvokeRequired)
{
try
{
Invoke(((Action)(() => OnDeviceError(e))));
}
catch (InvalidAsynchronousStateException) when (this.IsDisposed)
{
//
// We hit a (unavoidable) race condition where the
// window was being disposed during the call. We can
// just ignore that.
//
}
}
else
{
OnDeviceError(e);
}
}
/// <summary>
/// Process device closure.
/// </summary>
internal void ReceiveClose()
{
if (this.InvokeRequired)
{
try
{
Invoke(((Action)(() => OnDeviceClosed())));
}
catch (InvalidAsynchronousStateException) when (this.IsDisposed)
{
//
// We hit a (unavoidable) race condition where the
// window was being disposed during the call. We can
// just ignore that.
//
}
}
else
{
OnDeviceClosed();
}
}
//---------------------------------------------------------------------
// Events.
//---------------------------------------------------------------------
public event EventHandler? DimensionsChanged;
/// <summary>
/// Terminal is about to receive (and display) new data.
/// </summary>
public event EventHandler<VirtualTerminalOutputEventArgs>? Output;
/// <summary>
/// Terminal received user input that needs to be sent to the device.
/// </summary>
public event EventHandler<VirtualTerminalInputEventArgs>? UserInput;
/// <summary>
/// The device has failed.
/// </summary>
public event EventHandler<VirtualTerminalErrorEventArgs>? DeviceError;
/// <summary>
/// Device has been closed, this might be because the user ended the
/// session.
/// </summary>
public event EventHandler? DeviceClosed;
/// <summary>
/// Terminal theme changed.
/// </summary>
public event EventHandler? ThemeChanged;
protected virtual void OnDimensionsChanged()
{
this.DimensionsChanged?.Invoke(this, EventArgs.Empty);
}
protected virtual void OnTerminalScrolled(
int viewTop,
int viewHeight,
int bufferSize)
{
//
// Adjust the scrollbar maximum based on the unseen part, and set the
// current position.
//
this.scrollBar.Minimum = 0;
this.scrollBar.Maximum = bufferSize - viewHeight;
this.scrollBar.Value = viewTop;
}
/// <summary>
/// Invoked when the terminal received user input that needs to
/// be sent to the device.
/// </summary>
protected virtual void OnUserInput(string data)
{
if (this.currentSubclassedMessage == WindowMessage.WM_RBUTTONDOWN)
{
//
// Our subclass handles keyboard accelerators for pasting,
// and we make sure the pasted content is sanitized first.
//
// Right-click pasting is handled by the terminal itself
// (in src/cascadia/TerminalControl/HwndTerminal.cpp), but
// without sanitization.
//
// If we're receiving output during a WM_RBUTTONDOWN,
// then the output must be such unsanitized clipboard
// contents.
//
data = SanitizeTextForPasting(data);
}
this.UserInput?.Invoke(this, new VirtualTerminalInputEventArgs(data));
}
/// <summary>
/// Invoked when the device produced output that needs to be
/// sent to the terminal for rendering.
/// </summary>
protected virtual void OnOutputReceived(string data)
{
if (this.DesignMode)
{
return;
}
if (this.IsDisposed)
{
//
// It's not safe to touch the control anymore.
//
return;
}
if (this.terminal != null && this.CanUseTerminal)
{
NativeMethods.TerminalSendOutput(this.terminal, data);
}
this.Output?.Invoke(this, new VirtualTerminalOutputEventArgs(data));
}
protected virtual void OnDeviceError(Exception e)
{
this.DeviceError?.Invoke(this, new VirtualTerminalErrorEventArgs(e));
}
protected virtual void OnDeviceClosed()
{
this.DeviceClosed?.Invoke(this, EventArgs.Empty);
}
protected virtual void OnThemeChanged()
{
if (this.terminal == null || !this.CanUseTerminal)
{
//
// Too early or late to process this event, ignore.
//
return;
}
var theme = new TerminalTheme()
{
DefaultBackground = (uint)ColorTranslator.ToWin32(this.BackColor),
DefaultForeground = (uint)ColorTranslator.ToWin32(this.ForeColor),
DefaultSelectionBackground = (uint)ColorTranslator.ToWin32(this.SelectionBackColor),
SelectionBackgroundAlpha = this.SelectionBackgroundAlpha,
CursorStyle = this.caretStyle,
ColorTable = this.terminalColors.ToNative()
};
NativeMethods.TerminalSetTheme(
this.terminal,
theme,
this.Font.FontFamily.Name,
(short)this.Font.Size,
DeviceCapabilities.Current.Dpi);
this.ThemeChanged?.Invoke(this, EventArgs.Empty);
//
// Changing the font might have an impact on dimensions.
// Trigger a pseudo-resize to cause the terminal to re-calculate
// dimensions and bring our Dimensions property back into sync.
//
OnResize(EventArgs.Empty);
}
//---------------------------------------------------------------------
// Window events.
//---------------------------------------------------------------------
private void OnScrollbarScrolled(object sender, ScrollEventArgs e)
{
if (this.terminal != null && this.CanUseTerminal)
{
NativeMethods.TerminalUserScroll(
this.terminal,
e.NewValue);
}
}
private void OnScrollbarValueChanged(object sender, System.EventArgs e)
{
if (this.terminal != null && this.CanUseTerminal)
{
NativeMethods.TerminalUserScroll(
this.terminal,
this.scrollBar.Value);
}
}
protected override bool ProcessDialogKey(Keys keyData)
{
switch (keyData & ~(Keys.Shift | Keys.Control | Keys.Alt))
{
case Keys.Up:
case Keys.Down:
case Keys.Left:
case Keys.Right:
//
// Prevent the scrollbar from processing these keys.
//
return false;
default:
return base.ProcessDialogKey(keyData);
}
}
protected override void ScaleControl(SizeF factor, BoundsSpecified specified)
{
base.ScaleControl(factor, specified);
//
// The scrollbar width isn't adjusted automatically.
//
this.scrollBar.Width = LogicalToDeviceUnits(SystemInformation.VerticalScrollBarWidth);
}
//---------------------------------------------------------------------
// Overrides.
//---------------------------------------------------------------------
protected override void OnPaint(PaintEventArgs e)
{
if (this.DesignMode)
{
//
// Draw a placeholder where the terminal would appear.
//
e.Graphics.DrawRectangle(
SystemPens.Highlight,
this.Bounds);
TextRenderer.DrawText(
e.Graphics,
"Terminal",
this.font,
this.Bounds,
SystemColors.Control,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
};
base.OnPaint(e);
}
protected override void OnHandleCreated(EventArgs e)
{
CreateTerminalHandle();
base.OnHandleCreated(e);
}
protected override void OnResize(EventArgs e)
{
if (this.Width == 0 || this.Height == 0)
{
//
// This happens when the window is being minimized.
// We can ignore that.
//
return;
}
//
// Notify terminal so that it can adjust dimensions.
//
if (this.terminal != null && this.CanUseTerminal)
{
var scrollbarWidth = SystemInformation.VerticalScrollBarWidth;
var hr = NativeMethods.TerminalTriggerResize(
this.terminal,
(int)this.Width - scrollbarWidth,
(int)this.Height,
out var dimensions);
if (hr != 0)
{
throw VirtualTerminalException.FromHresult(
hr,
"Adjusting terminal size failed");
}
Debug.Assert(dimensions.X <= ushort.MaxValue);
Debug.Assert(dimensions.Y <= ushort.MaxValue);
this.Dimensions = new PseudoTerminalSize(
(ushort)dimensions.X,
(ushort)dimensions.Y);
Debug.Assert(this.Dimensions.Width > 0);
Debug.Assert(this.Dimensions.Height > 0);
}
base.OnResize(e);
}
protected override void DestroyHandle()
{
this.terminalSubclass?.Dispose();
this.terminal?.Dispose();
this.terminal = null;
base.DestroyHandle();
}
protected override void OnGotFocus(EventArgs e)
{
if (this.DesignMode)
{
return;
}
Debug.Assert(this.terminalSubclass != null);
Debug.Assert(this.terminal != null);
Debug.Assert(this.terminalSubclass != null);
NativeMethods.SetFocus(this.terminalSubclass!.WindowHandle);
base.OnGotFocus(e);
}
protected override void OnForeColorChanged(EventArgs e)
{
OnThemeChanged();
base.OnForeColorChanged(e);
}
protected override void OnBackColorChanged(EventArgs e)
{
OnThemeChanged();
base.OnBackColorChanged(e);
}
protected override void OnPreviewKeyDown(PreviewKeyDownEventArgs e)
{
if (e.KeyCode == Keys.Tab)
{
//
// Process TAB as a normal input key instead of moving
// the focus away from this control.
//
e.IsInputKey = true;
}
base.OnPreviewKeyDown(e);
}
//---------------------------------------------------------------------
// Terminal subclass.
//---------------------------------------------------------------------
private bool terminalWindowDestructionBegun = false;
private bool ignoreWmCharBecauseOfAccelerator = false;
private string? selectionToCopyInKeyUp = null;
private WindowMessage? currentSubclassedMessage;
private void TerminalSubclassWndProc(ref Message m)
{
bool IsAcceleratorForCopyingCurrentSelection(Keys key)
{
if (ModifierKeys == Keys.None && key == Keys.Enter)
{
//
// Consistent with the classic Windows console, treat
// Enter as a "copy" command.
//
return true;
}
else if (ModifierKeys == Keys.Control && key == Keys.Insert)
{
return this.EnableCtrlInsert;
}
else if (ModifierKeys == Keys.Control && key == Keys.C)
{
//
// NB. Powershell handles Ctrl+C itself, but cmd and bash
// don't.
//
return this.EnableCtrlC;
}
else
{
return false;
};
}
bool IsAcceleratorForPasting(Keys key)
{
if (ModifierKeys == Keys.Shift && key == Keys.Insert)
{
return this.EnableShiftInsert;
}
else if (ModifierKeys == Keys.Control && key == Keys.V)
{
return this.EnableCtrlV;
}
else
{
return false;
};
}
bool IsAcceleratorForScrollingToTop(Keys key)
{
if (ModifierKeys == Keys.Control && key == Keys.Home)
{
return this.EnableCtrlHomeEnd;
}
else
{
return false;
};
}
bool IsAcceleratorForScrollingToBottom(Keys key)
{
if (ModifierKeys == Keys.Control && key == Keys.End)
{
return this.EnableCtrlHomeEnd;
}
else
{
return false;
};
}
bool IsAcceleratorForScrollingUpOnePage(Keys key)
{
if (ModifierKeys == Keys.Control && key == Keys.PageUp)
{
return this.EnableCtrlPageUpDown;
}
else
{
return false;
};
}
bool IsAcceleratorForScrollingDownOnePage(Keys key)
{
if (ModifierKeys == Keys.Control && key == Keys.PageDown)
{
return this.EnableCtrlPageUpDown;
}
else
{
return false;
};
}
/// <summary>
/// Scroll the terminal up or down by a certain number of lines.
/// </summary>
void ScrollTerminal(int linesDelta)
{
var currentValue = this.scrollBar.Value;
if (linesDelta > 0)
{
//
// Scrolling up.
//
this.scrollBar.Value = Math.Max(
this.scrollBar.Minimum,
currentValue - linesDelta);
}
else
{
//
// Scrolling down.
//
this.scrollBar.Value = Math.Min(
this.scrollBar.Maximum,
currentValue - linesDelta);
}
}
Debug.Assert(!this.DesignMode);
var msgId = (WindowMessage)m.Msg;
if (msgId == WindowMessage.WM_DESTROY)
{
//
// Pass through and set a flag.
//
// From this point on, it's no longer safe to make
// any further TerminalXxx P/Invoke calls. In
// particular, we must ignore the WM_KILLFOCUS
// message that might be coming next.
//
this.currentSubclassedMessage = null;
this.terminalWindowDestructionBegun = true;
SubclassCallback.DefaultWndProc(ref m);
return;
}
else if (this.terminalWindowDestructionBegun)
{
//
// Pass through and skip all subclass logic.
//
this.currentSubclassedMessage = null;
SubclassCallback.DefaultWndProc(ref m);
return;
}
else
{
this.currentSubclassedMessage = msgId;
}
var terminalHandle = Invariant.ExpectNotNull(this.terminal, "Terminal");
switch (msgId)
{
case WindowMessage.WM_SETFOCUS:
{
NativeMethods.TerminalSetFocus(terminalHandle);
this.caretBlinkTimer.Start();
break;
}
case WindowMessage.WM_KILLFOCUS:
{
NativeMethods.TerminalKillFocus(terminalHandle);
this.caretBlinkTimer.Stop();
NativeMethods.TerminalSetCursorVisible(terminalHandle, false);
break;
}
case WindowMessage.WM_MOUSEACTIVATE:
{
Focus();
NativeMethods.TerminalSetFocus(terminalHandle);
break;
}
case WindowMessage.WM_SYSKEYDOWN:
case WindowMessage.WM_KEYDOWN:
{
var keyParams = new WmKeyUpDownParams(m);
NativeMethods.TerminalSetCursorVisible(terminalHandle, true);
this.caretBlinkTimer.Start();
if (IsAcceleratorForCopyingCurrentSelection((Keys)keyParams.VirtualKey) &&
NativeMethods.TerminalIsSelectionActive(terminalHandle))
{
//
// Begin "copy" operation.
//
// Cache the selected text so that we can process it
// in WM_KEYUP.
//
// NB. We must not pass this key event to the terminal.
//
this.ignoreWmCharBecauseOfAccelerator = true;
this.selectionToCopyInKeyUp =
NativeMethods.TerminalGetSelection(terminalHandle);
}
else if (IsAcceleratorForPasting((Keys)keyParams.VirtualKey))
{
//
// We'll handle the paste in WM_KEYUP.
//
// NB. We must not pass this key event to the terminal.
//
this.ignoreWmCharBecauseOfAccelerator = true;
}
else if (IsAcceleratorForScrollingToTop((Keys)keyParams.VirtualKey))
{
this.scrollBar.Value = 0;
}
else if (IsAcceleratorForScrollingToBottom((Keys)keyParams.VirtualKey))
{
this.scrollBar.Value = this.scrollBar.Maximum;
}
else if (IsAcceleratorForScrollingUpOnePage((Keys)keyParams.VirtualKey))
{
ScrollTerminal(this.Dimensions.Height);
}
else if (IsAcceleratorForScrollingDownOnePage((Keys)keyParams.VirtualKey))
{
ScrollTerminal(-this.Dimensions.Height);
}
else
{
NativeMethods.TerminalSendKeyEvent(
terminalHandle,
keyParams.VirtualKey,
keyParams.ScanCode,
keyParams.Flags,
true);
}
break;
}
case WindowMessage.WM_SYSKEYUP:
case WindowMessage.WM_KEYUP:
{
var keyParams = new WmKeyUpDownParams(m);
if (IsAcceleratorForCopyingCurrentSelection((Keys)keyParams.VirtualKey) &&
this.selectionToCopyInKeyUp != null)
{
//
// Continue the "copy" operation begun in WM_KEYDOWN.
//
// NB. We must not pass this key event to the
// terminal.
//
try
{
if (!string.IsNullOrWhiteSpace(this.selectionToCopyInKeyUp))
{
ClipboardUtil.SetText(this.selectionToCopyInKeyUp);
}
}
catch (ExternalException)
{
//
// Clipboard busy, ignore.
//
}
NativeMethods.TerminalClearSelection(terminalHandle);
this.selectionToCopyInKeyUp = null;
}
else if (IsAcceleratorForPasting((Keys)keyParams.VirtualKey))
{
try
{
var contents = Clipboard.GetText();
if (!string.IsNullOrWhiteSpace(contents))
{
OnUserInput(SanitizeTextForPasting(contents));
}
}
catch (ExternalException)
{
//
// Clipboard busy, ignore.
//
}
}
else if (
IsAcceleratorForScrollingToTop((Keys)keyParams.VirtualKey) ||
IsAcceleratorForScrollingToBottom((Keys)keyParams.VirtualKey) ||
IsAcceleratorForScrollingUpOnePage((Keys)keyParams.VirtualKey) ||
IsAcceleratorForScrollingDownOnePage((Keys)keyParams.VirtualKey))
{
//
// We handled these in WM_KEYUP already, so don't pass a stray
// WM_KEYDOWN to the terminal.
//
}
else
{
NativeMethods.TerminalSendKeyEvent(
terminalHandle,
keyParams.VirtualKey,
keyParams.ScanCode,
keyParams.Flags,
false);
}
break;
}
case WindowMessage.WM_CHAR:
{
if (this.ignoreWmCharBecauseOfAccelerator)
{
//
// Ignore because it's part of an accelerator.
//
this.ignoreWmCharBecauseOfAccelerator = false;
}
else
{
var charParams = new WmCharParams(m);
NativeMethods.TerminalSendCharEvent(
terminalHandle,
charParams.Character,
charParams.ScanCode,
charParams.Flags);
}
break;
}
case WindowMessage.WM_LBUTTONDOWN:
case WindowMessage.WM_RBUTTONDOWN:
{
if (NativeMethods.TerminalIsSelectionActive(terminalHandle))
{
//
// Get (and clear) selected text.
//
var selection = NativeMethods.TerminalGetSelection(terminalHandle);
if (string.IsNullOrWhiteSpace(selection))
{
//
// Clear clipboard instead of copying some whitespace.
// This is concistent with how the Windows console
// handles this case.
//
ClipboardUtil.Clear();
}
else
{
ClipboardUtil.SetText(selection);
}
}
//
// Continue processing message.
//
// NB. In case of WM_RBUTTONDOWN, the terminal will cause
// the copied text to be pasted right away.
//
goto default;
}
case WindowMessage.WM_MOUSEWHEEL:
{
//
// The hi-word contains the the distance, in multiples
// of 120.
//
var delta = (short)(((long)m.WParam) >> 16);
if (Control.ModifierKeys.HasFlag(Keys.Control))
{
//
// Control key pressed -> Zoom.
//
// We only need the sign of the delta to know
// whether to zoom in or out.
//
var oldFont = this.Font;
var newFontSize = (delta > 0)
? Math.Min(this.Font.Size + 1, MaximumFontSizeForScrolling)
: Math.Max(this.Font.Size - 1, MinimumFontSizeForScrolling);
this.Font = new Font(
this.Font.FontFamily,
newFontSize);
oldFont.Dispose();
}
else
{
//
// Control key not pressed -> scroll.
//
// Translate delta to the number of lines (+/-) to scroll.
//
var linesDelta = delta / 120 *
Math.Max(SystemInformation.MouseWheelScrollLines, 1);
ScrollTerminal(linesDelta);
}
break;
}
default:
SubclassCallback.DefaultWndProc(ref m);
break;
}
}
//---------------------------------------------------------------------
// Testing-only methods.
//---------------------------------------------------------------------
internal bool TerminalHandleCreated
{
get => this.terminal != null;
}
internal void SimulateKey(Keys keyCode)
{
Debug.Assert(!this.InvokeRequired, "Must be called on GUI thread");
var subclass = Invariant.ExpectNotNull(
this.terminalSubclass,
"Subclass");
foreach (var message in KeyboardUtil.ToMessageSequence(
subclass.WindowHandle,
keyCode))
{
var m = message;
TerminalSubclassWndProc(ref m);
}
}
internal void SimulateSend(string data)
{
Debug.Assert(!this.InvokeRequired, "Must be called on GUI thread");
OnUserInput(data);
}
}
}