Skip to content

Commit

Permalink
Add a Windows.UI.Text.Core IME overlay to TerminalControl (#1919)
Browse files Browse the repository at this point in the history
TerminalControl doesn't use any of the built in text input and edit
controls provided by XAML for text input, which means TermianlControl
needs to communicate with the Text Services Framework (TSF) in order to
provide Input Method Editor (IME) support.  Just like the rest of
Terminal we get to take advantage of newer APIs (Windows.UI.Text.Core)
namespace to provide support vs. the old TSF 1.0.

Windows.UI.Text.Core handles communication between a text edit control
and the text services primarily through a CoreTextEditContext object.

This change introduces a new UserControl TSFInputControl which is a
custom EditControl similar to the CustomEditControl sample[1].

TSFInputControl is similar (overlay with IME text) to how old console
(conimeinfo) handled IME. 

# Details
TSFInputControl is a Windows.UI.Xaml.Controls.UserControl

TSFInputControl contains a Canvas control for absolution positioning a
TextBlock control within its containing control (TerminalControl).

The TextBlock control is used for displaying candidate text from the
IME.  When the user makes a choice in the IME the TextBlock is cleared
and the text is written to the Terminal buffer like normal text.

TSFInputControl creates an instance of the CoreTextEditContext and
attaches appropriate event handlers to CoreTextEditContext in order to
interact with the IME.

A good write-up on how to interact with CoreTextEditContext can be found
here[2].

## Text Updates
Text updates from the IME come in on the TextUpdating event handler,
text updates are stored in an internal buffer (_inputBuffer).

## Completed Text
Once a user selects a text in the IME, the CompositionCompleted handler
is invoked.  The input buffer (_inputBuffer) is written to the Terminal
buffer, _inputBuffer is cleared and Canvas and TextBlock controls are
hidden until the user starts a composition session again.

## Positioning
Telling the IME where to properly position itself was the hardest part
of this change.  The IME expects to know it's location in screen
coordinates as supposed to client coordinates.  This is pretty easy if
you are a pure UWP, but since we are hosted inside a XAMLIsland the
client to screen coordinate translation is a little harder.  

### Calculating Screen Coordinates
1. Obtaining the Window position in Screen coordinates.
2. Determining the Client coordinate of the cursor.
3. Converting the Client coordinate of the cursor to Screen coordinates.
4. Offsetting the X and Y coordinate of the cursor by the position of
   the TerminalControl within the window (tabs if present, margins, etc..).
5. Applying any scale factor of the display.

Once we have the right position in screen coordinates, this is supplied
in the LayoutBounds of the CoreTextLayoutRequestedEventArgs which lets
the IME know where to position itself on the Screen.

## Font Information/Cursor/Writing to Terminal
3 events were added to the TSFInputControl to create a loosely-coupled
implementation between the TerminalControl and the TSFInputControl.
These events are used for obtaining Font information from the
TerminalControl, getting the Cursor position and writing to the terminal
buffer.

## Known Issues

- Width of TextBlock is hardcoded to 200 pixels and most likely should
  adjust to the available width of the current input line on the console
  (#3640)
- Entering text in the middle of an existing set of text has TextBlock
  render under existing text. Current Console behavior here isn't good
  experience either (writes over text)
- Text input at edges of window is clipped versus wrapping around to
  next line.  This isn't any worse than the original command line, but
  Terminal should be better (#3657)

## Future Considerations
Ideally, we'd be able to interact with the console buffer directly and
replace characters as the user types. 

## Validation
General steps to try functionality
- Open Console
- Switch to Simplified Chinese (Shortcut: Windows+Spacebar)
- Switch to Chinese mode on language bar

Scenarios validated:
- As user types unformatted candidates appear on command line and IME
  renders in correct position under unformatted characters.
- User can dismiss IME and text doesn't appear on command line 
- Switch back to English mode, functions like normal
- New tab has proper behavior
- Switching between tabs has proper behavior
- Switching away from Terminal Window with IME present causes IME to
  disappear

[1]: https://github.com/Microsoft/Windows-universal-samples/tree/master/Samples/CustomEditControl
[2]: https://docs.microsoft.com/en-us/windows/uwp/design/input/custom-text-input

Closes #459
Closes #2213
Closes #3641
  • Loading branch information
philnach authored and DHowett committed Nov 22, 2019
1 parent 99a8337 commit 62d7f11
Show file tree
Hide file tree
Showing 9 changed files with 583 additions and 16 deletions.
349 changes: 349 additions & 0 deletions src/cascadia/TerminalControl/TSFInputControl.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,349 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

#include "pch.h"
#include "TSFInputControl.h"
#include "TSFInputControl.g.cpp"

#include <Utils.h>

using namespace winrt::Windows::Foundation;
using namespace winrt::Windows::Graphics::Display;
using namespace winrt::Windows::UI::Core;
using namespace winrt::Windows::UI::Text;
using namespace winrt::Windows::UI::Text::Core;
using namespace winrt::Windows::UI::Xaml;

namespace winrt::Microsoft::Terminal::TerminalControl::implementation
{
TSFInputControl::TSFInputControl() :
_editContext{ nullptr }
{
_Create();
}

// Method Description:
// - Creates XAML controls for displaying user input and hooks up CoreTextEditContext handlers
// for handling text input from the Text Services Framework.
// Arguments:
// - <none>
// Return Value:
// - <none>
void TSFInputControl::_Create()
{
// TextBlock for user input form TSF
_textBlock = Controls::TextBlock();
_textBlock.Visibility(Visibility::Collapsed);
_textBlock.IsTextSelectionEnabled(false);
_textBlock.TextDecorations(TextDecorations::Underline);

// Canvas for controlling exact position of the TextBlock
_canvas = Windows::UI::Xaml::Controls::Canvas();
_canvas.Visibility(Visibility::Collapsed);

// add the Textblock to the Canvas
_canvas.Children().Append(_textBlock);

// set the content of this control to be the Canvas
this->Content(_canvas);

// Create a CoreTextEditingContext for since we are acting like a custom edit control
auto manager = Core::CoreTextServicesManager::GetForCurrentView();
_editContext = manager.CreateEditContext();

// sets the Input Pane display policy to Manual for now so that it can manually show the
// software keyboard when the control gains focus and dismiss it when the control loses focus.
// TODO GitHub #3639: Should Input Pane display policy be Automatic
_editContext.InputPaneDisplayPolicy(Core::CoreTextInputPaneDisplayPolicy::Manual);

// set the input scope to Text because this control is for any text.
_editContext.InputScope(Core::CoreTextInputScope::Text);

_editContext.TextRequested({ this, &TSFInputControl::_textRequestedHandler });

_editContext.SelectionRequested({ this, &TSFInputControl::_selectionRequestedHandler });

_editContext.FocusRemoved({ this, &TSFInputControl::_focusRemovedHandler });

_editContext.TextUpdating({ this, &TSFInputControl::_textUpdatingHandler });

_editContext.SelectionUpdating({ this, &TSFInputControl::_selectionUpdatingHandler });

_editContext.FormatUpdating({ this, &TSFInputControl::_formatUpdatingHandler });

_editContext.LayoutRequested({ this, &TSFInputControl::_layoutRequestedHandler });

_editContext.CompositionStarted({ this, &TSFInputControl::_compositionStartedHandler });

_editContext.CompositionCompleted({ this, &TSFInputControl::_compositionCompletedHandler });
}

// Method Description:
// - NotifyFocusEnter handler for notifying CoreEditTextContext of focus enter
// when TerminalControl receives focus.
// Arguments:
// - <none>
// Return Value:
// - <none>
void TSFInputControl::NotifyFocusEnter()
{
if (_editContext != nullptr)
{
_editContext.NotifyFocusEnter();
}
}

// Method Description:
// - NotifyFocusEnter handler for notifying CoreEditTextContext of focus leaving.
// when TerminalControl no longer has focus.
// Arguments:
// - <none>
// Return Value:
// - <none>
void TSFInputControl::NotifyFocusLeave()
{
if (_editContext != nullptr)
{
// _editContext.NotifyFocusLeave(); TODO GitHub #3645: Enabling causes IME to no longer show up, need to determine if required
}
}

// Method Description:
// - Handler for LayoutRequested event by CoreEditContext responsible
// for returning the current position the IME should be placed
// in screen coordinates on the screen. TSFInputControls internal
// XAML controls (TextBlock/Canvas) are also positioned and updated.
// NOTE: documentation says application should handle this event
// Arguments:
// - sender: CoreTextEditContext sending the request.
// - args: CoreTextLayoutRequestedEventArgs to be updated with position information.
// Return Value:
// - <none>
void TSFInputControl::_layoutRequestedHandler(CoreTextEditContext sender, CoreTextLayoutRequestedEventArgs const& args)
{
auto request = args.Request();

// Get window in screen coordinates, this is the entire window including tabs
const auto windowBounds = CoreWindow::GetForCurrentThread().Bounds();

// Get the cursor position in text buffer position
auto cursorArgs = winrt::make_self<CursorPositionEventArgs>();
_CurrentCursorPositionHandlers(*this, *cursorArgs);
const COORD cursorPos = { gsl::narrow_cast<SHORT>(cursorArgs->CurrentPosition().X), gsl::narrow_cast<SHORT>(cursorArgs->CurrentPosition().Y) };

// Get Font Info as we use this is the pixel size for characters in the display
auto fontArgs = winrt::make_self<FontInfoEventArgs>();
_CurrentFontInfoHandlers(*this, *fontArgs);

const float fontWidth = fontArgs->FontSize().Width;
const float fontHeight = fontArgs->FontSize().Height;

// Convert text buffer cursor position to client coordinate position within the window
COORD clientCursorPos;
COORD screenCursorPos;
THROW_IF_FAILED(ShortMult(cursorPos.X, gsl::narrow<SHORT>(fontWidth), &clientCursorPos.X));
THROW_IF_FAILED(ShortMult(cursorPos.Y, gsl::narrow<SHORT>(fontHeight), &clientCursorPos.Y));

// Convert from client coordinate to screen coordinate by adding window position
THROW_IF_FAILED(ShortAdd(clientCursorPos.X, gsl::narrow_cast<SHORT>(windowBounds.X), &screenCursorPos.X));
THROW_IF_FAILED(ShortAdd(clientCursorPos.Y, gsl::narrow_cast<SHORT>(windowBounds.Y), &screenCursorPos.Y));

// get any offset (margin + tabs, etc..) of the control within the window
const auto offsetPoint = this->TransformToVisual(nullptr).TransformPoint(winrt::Windows::Foundation::Point(0, 0));

// add the margin offsets if any
const auto currentMargin = this->Margin();
THROW_IF_FAILED(ShortAdd(screenCursorPos.X, gsl::narrow_cast<SHORT>(offsetPoint.X), &screenCursorPos.X));
THROW_IF_FAILED(ShortAdd(screenCursorPos.Y, gsl::narrow_cast<SHORT>(offsetPoint.Y), &screenCursorPos.Y));

// Get scale factor for view
const double scaleFactor = DisplayInformation::GetForCurrentView().RawPixelsPerViewPixel();

// Set the selection layout bounds
Rect selectionRect = Rect(screenCursorPos.X, screenCursorPos.Y, 0, fontHeight);
request.LayoutBounds().TextBounds(ScaleRect(selectionRect, scaleFactor));

// Set the control bounds of the whole control
Rect controlRect = Rect(screenCursorPos.X, screenCursorPos.Y, 0, fontHeight);
request.LayoutBounds().ControlBounds(ScaleRect(controlRect, scaleFactor));

// position textblock to cursor position
_canvas.SetLeft(_textBlock, clientCursorPos.X);
_canvas.SetTop(_textBlock, static_cast<double>(clientCursorPos.Y));

// width is cursor to end of canvas
_textBlock.Width(200); // TODO GitHub #3640: Determine proper Width
_textBlock.Height(fontHeight);

// calculate FontSize in pixels from DIPs
const double fontSizePx = (fontHeight * 72) / USER_DEFAULT_SCREEN_DPI;
_textBlock.FontSize(fontSizePx);

_textBlock.FontFamily(Media::FontFamily(fontArgs->FontFace()));
}

// Method Description:
// - Handler for CompositionStarted event by CoreEditContext responsible
// for making internal TSFInputControl controls visisble.
// Arguments:
// - sender: CoreTextEditContext sending the request. Not used in method.
// - args: CoreTextCompositionStartedEventArgs. Not used in method.
// Return Value:
// - <none>
void TSFInputControl::_compositionStartedHandler(CoreTextEditContext sender, CoreTextCompositionStartedEventArgs const& /*args*/)
{
_canvas.Visibility(Visibility::Visible);
_textBlock.Visibility(Visibility::Visible);
}

// Method Description:
// - Handler for CompositionCompleted event by CoreEditContext responsible
// for making internal TSFInputControl controls visisble.
// Arguments:
// - sender: CoreTextEditContext sending the request. Not used in method.
// - args: CoreTextCompositionCompletedEventArgs. Not used in method.
// Return Value:
// - <none>
void TSFInputControl::_compositionCompletedHandler(CoreTextEditContext sender, CoreTextCompositionCompletedEventArgs const& /*args*/)
{
// only need to do work if the current buffer has text
if (!_inputBuffer.empty())
{
const auto hstr = to_hstring(_inputBuffer.c_str());

// call event handler with data handled by parent
_compositionCompletedHandlers(hstr);

// clear the buffer for next round
_inputBuffer.clear();
_textBlock.Text(L"");

// tell the input server that we've cleared the buffer
CoreTextRange emptyTextRange;
emptyTextRange.StartCaretPosition = 0;
emptyTextRange.EndCaretPosition = 0;

// indicate text is now 0
_editContext.NotifyTextChanged(emptyTextRange, 0, emptyTextRange);
_editContext.NotifySelectionChanged(emptyTextRange);

// hide the controls until composition starts again
_canvas.Visibility(Visibility::Collapsed);
_textBlock.Visibility(Visibility::Collapsed);
}
}

// Method Description:
// - Handler for FocusRemoved event by CoreEditContext responsible
// for removing focus for the TSFInputControl control accordingly
// when focus was forecibly removed from text input control. (TODO GitHub #3644)
// NOTE: Documentation says application should handle this event
// Arguments:
// - sender: CoreTextEditContext sending the request. Not used in method.
// - object: CoreTextCompositionStartedEventArgs. Not used in method.
// Return Value:
// - <none>
void TSFInputControl::_focusRemovedHandler(CoreTextEditContext sender, winrt::Windows::Foundation::IInspectable const& /*object*/)
{
}

// Method Description:
// - Handler for TextRequested event by CoreEditContext responsible
// for returning the range of text requested.
// NOTE: Documentation says application should handle this event
// Arguments:
// - sender: CoreTextEditContext sending the request. Not used in method.
// - args: CoreTextTextRequestedEventArgs to be updated with requested range text.
// Return Value:
// - <none>
void TSFInputControl::_textRequestedHandler(CoreTextEditContext sender, CoreTextTextRequestedEventArgs const& args)
{
// the range the TSF wants to know about
const auto range = args.Request().Range();

try
{
const auto textRequested = _inputBuffer.substr(range.StartCaretPosition, static_cast<size_t>(range.EndCaretPosition) - static_cast<size_t>(range.StartCaretPosition));

args.Request().Text(winrt::to_hstring(textRequested.c_str()));
}
CATCH_LOG();
}

// Method Description:
// - Handler for SelectionRequested event by CoreEditContext responsible
// for returning the currently selected text.
// TSFInputControl currently doesn't allow selection, so nothing happens.
// NOTE: Documentation says application should handle this event
// Arguments:
// - sender: CoreTextEditContext sending the request. Not used in method.
// - args: CoreTextSelectionRequestedEventArgs for providing data for the SelectionRequested event. Not used in method.
// Return Value:
// - <none>
void TSFInputControl::_selectionRequestedHandler(CoreTextEditContext sender, CoreTextSelectionRequestedEventArgs const& /*args*/)
{
}

// Method Description:
// - Handler for SelectionUpdating event by CoreEditContext responsible
// for handling modifications to the range of text currently selected.
// TSFInputControl doesn't currently allow selection, so nothing happens.
// NOTE: Documentation says application should set its selection range accordingly
// Arguments:
// - sender: CoreTextEditContext sending the request. Not used in method.
// - args: CoreTextSelectionUpdatingEventArgs for providing data for the SelectionUpdating event. Not used in method.
// Return Value:
// - <none>
void TSFInputControl::_selectionUpdatingHandler(CoreTextEditContext sender, CoreTextSelectionUpdatingEventArgs const& /*args*/)
{
}

// Method Description:
// - Handler for TextUpdating event by CoreEditContext responsible
// for handling text updates.
// Arguments:
// - sender: CoreTextEditContext sending the request. Not used in method.
// - args: CoreTextTextUpdatingEventArgs contains new text to update buffer with.
// Return Value:
// - <none>
void TSFInputControl::_textUpdatingHandler(CoreTextEditContext sender, CoreTextTextUpdatingEventArgs const& args)
{
const auto text = args.Text();
const auto range = args.Range();

try
{
_inputBuffer = _inputBuffer.replace(
range.StartCaretPosition,
static_cast<size_t>(range.EndCaretPosition) - static_cast<size_t>(range.StartCaretPosition),
text.c_str());

_textBlock.Text(_inputBuffer);

// Notify the TSF that the update succeeded
args.Result(CoreTextTextUpdatingResult::Succeeded);
}
catch (...)
{
LOG_CAUGHT_EXCEPTION();

// indicate updating failed.
args.Result(CoreTextTextUpdatingResult::Failed);
}
}

// Method Description:
// - Handler for FormatUpdating event by CoreEditContext responsible
// for handling different format updates for a particular range of text.
// TSFInputControl doesn't do anything with this event.
// Arguments:
// - sender: CoreTextEditContext sending the request. Not used in method.
// - args: CoreTextFormatUpdatingEventArgs Provides data for the FormatUpdating event. Not used in method.
// Return Value:
// - <none>
void TSFInputControl::_formatUpdatingHandler(CoreTextEditContext sender, CoreTextFormatUpdatingEventArgs const& /*args*/)
{
}

DEFINE_EVENT(TSFInputControl, CompositionCompleted, _compositionCompletedHandlers, TerminalControl::CompositionCompletedEventArgs);
}
Loading

0 comments on commit 62d7f11

Please sign in to comment.