Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Support text editing voiceover feedback in macOS #25600

Merged
merged 1 commit into from
Jun 1, 2021

Conversation

chunhtai
Copy link
Contributor

@chunhtai chunhtai commented Apr 14, 2021

Fixes flutter/flutter#77834

design doc go/macos-text-editing-feedback

Pre-launch Checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • I read the Tree Hygiene wiki page, which explains my responsibilities.
  • I read and followed the Flutter Style Guide and the C++, Objective-C, Java style guides.
  • I listed at least one issue that this PR fixes in the description above.
  • I added new tests to check the change I am making or feature I am adding, or Hixie said the PR is test-exempt. See testing the engine for instructions on
    writing and running engine tests.
  • I updated/added relevant documentation (doc comments with ///).
  • I signed the CLA.
  • All existing and new tests are passing.
  • The reviewer has submitted any presubmit flakes in this PR using the engine presubmit flakes form before re-triggering the failure.

If you need help, consider asking for advice on the #hackers-new channel on Discord.

@chunhtai
Copy link
Contributor Author

Hi @dnfield @cbracken Can you take a look at this PR? I have a design doc the outline the entire approach go/macos-text-editing-feedback

also cc @gaaclarke for that weird __unsafe_unretained issue for NSTextView

@chunhtai chunhtai marked this pull request as ready for review April 21, 2021 20:32
@chinmaygarde
Copy link
Member

@cbracken is OOO today and @dnfield is probably busy with the Android 1x1 pbuffer effort. Is there anyone else we can ping to unblock? If not, lets wait till next week.

@chunhtai
Copy link
Contributor Author

maybe @dkwingsmt for keyboard changes and @stuartmorgan for generic macos change

@dnfield
Copy link
Contributor

dnfield commented Apr 22, 2021

Sorry, I lost track of this, but can work on reviewing it. It would probably be good for @stuartmorgan or @cbracken to look as well if they're available.

@dnfield
Copy link
Contributor

dnfield commented Apr 22, 2021

(mainly because my head ends up stuck in MRC mode, and I'm less certain about how things end up working with ARC :)

Copy link
Contributor

@dnfield dnfield left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't look super closely at the test files, and I may be making some MRC minded mistakes, but overall:

  • We should be cautious about accessing the view without checking that it's loaded, otherwise we could unintentionally force it to load when it shouldn't.
  • I'm curious about assuming the type of the app delegate. I think if we can avoid doing that it will be better, even if we're not already supporting it elsewhere.
  • The unsafe unassigned seems fishy to me.

Comment on lines 136 to 140
//------------------------------------------------------------------------------
/// @brief Gets the owner of this platform node delegate. This is useful
/// when you want to get the information surround this platform
/// node delegate, e.g. the global rect of this platform node
/// delegate.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please mention which task runner this is safe to use on. I assume Platform.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not too familiar with this part, what makes some thing safe to run on platform thread vs the other. Also The accessibility birdge class should only be used in embedding code, can embedding code run things on UI thread?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fml weaks are only safe to use on the thread they were created on. The embedder can access the other task runners.

[strong_engine.viewController.view convertRectFromBacking:ns_local_bounds];
NSRect ns_window_bounds = [strong_engine.viewController.view convertRect:ns_view_bounds
toView:nil];
[strong_engine.viewController.flutterView convertRectFromBacking:ns_local_bounds];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here as well: we should either be asserting that the view is loaded, or fizzling out/returning a zero rect if it's not.

#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterView.h"

@interface FlutterViewController ()

// The FlutterView for this view controller.
@property(nonatomic, readonly, nullable) FlutterView* flutterView;
@property(nonatomic, strong, nullable) FlutterView* flutterView;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is the internal header, but I think we probably still want this ot be readonly in the header. We can redefine it as readwrite in the implementation file instead.

/**
* The text input plugin that handles text editing state for text fields.
*/
@property(nonatomic, strong, nonnull) FlutterTextInputPlugin* textInputPlugin;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not readonly?

@dnfield dnfield requested a review from stuartmorgan-g April 22, 2021 21:51
Copy link
Contributor

@stuartmorgan-g stuartmorgan-g left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a first pass; I haven't gone over everything in detail (especially once I hit the window delegate issue described below).

@@ -29,6 +29,7 @@ AccessibilityBridge::AccessibilityBridge(
AccessibilityBridge::~AccessibilityBridge() {
event_generator_.ReleaseTree();
tree_.RemoveObserver(static_cast<ui::AXTreeObserver*>(this));
id_wrapper_map_.clear();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need to clear an ivar in the destructor? Is this enforcing a specific ordering? If so, it absolutely needs a comment explaining that so someone doesn't just remove it.

/**
* A plugin to handle text input.
*
* Responsible for bridging the native macOS text input system with the Flutter framework text
* editing classes, via system channels.
*
* This is not an FlutterPlugin since it needs access to FlutterViewController internals, so needs
* to be managed differently.
* to be managed differently. The FlutterViewController has the ownership of this plugin.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reason for adding this comment?

I am generally very skeptical of comments that make assertions about code above this class. If someone refactors FVC at some point and this is no longer the case, what are the chances they will find and update this comment?

Sometimes the benefits outweigh the risk of the comments becoming wrong, but I'm not clear what the benefits are here.

@interface FlutterTextInputPlugin : NSTextView <FlutterKeySecondaryResponder>

/**
* The native text field this backed by this plugin as its field editor.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not able to parse this sentence (and I'm not sure what it's intended to say).


/**
* Initializes a text input plugin that coordinates key event handling with |viewController|.
*/
- (instancetype)initWithViewController:(FlutterViewController*)viewController;

/**
* Where this plugin is the first responder of this NSWindow.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whether

@end

/**
* Private interface declaration for FlutterNativeTextFieldHidder.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hider

Although really this whole line should be removed; comments should not restate obvious aspects of the language itself; anyone familiar with ObjC knows that this is an interface declaration. Just like we don't write comments like:

// This is declaring an integer variable:
int x;

/**
* Private interface declaration for FlutterNativeTextFieldHidder.
*
* This NSView is the content view of the FlutterViewController. This view
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/This NSView is the/The/ (see rationale above)

}

- (void)viewDidLoad {
FlutterAppDelegate* appDelegate = (FlutterAppDelegate*)[NSApp delegate];
self.windowDelegate = [[FlutterWindowDelegate alloc] initWithController:self];
appDelegate.mainFlutterWindow.delegate = _windowDelegate;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a non-starter; just as we don't own the application, we don't own the window. This could stomp a developer's window delegate, or they could stomp this.

If a window-level hook is the only way to make this workaround function, then you'll need to wire up an explicit way for an application to delegate to Flutter (likely via FVC) and then make the template set up a skeleton window delegate that explicitly does that forwarding, with a comment explaining why, and what to do it they want to have a different delegate.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out. There may be a way to inject field editor without windows delegate if i can create the custom NSTextFieldCell. I have to test it out.


- (id)windowWillReturnFieldEditor:(NSWindow*)sender toObject:(id)client {
if ([client isKindOfClass:[FlutterTextField class]]) {
return _controller.textInputPlugin;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if there is more than one Flutter view in the window?

@chunhtai chunhtai force-pushed the issues/77834-real-exp branch 4 times, most recently from 9ffa362 to c8d40c2 Compare April 28, 2021 17:10
@chunhtai
Copy link
Contributor Author

@stuartmorgan @dnfield Thanks for the review, I have addressed all the comments, the changes are in the second commit.

@chunhtai
Copy link
Contributor Author

chunhtai commented May 4, 2021

A friendly bump

/// @brief Gets the owner of this platform node delegate. This is useful
/// when you want to get the information surround this platform
/// node delegate, e.g. the global rect of this platform node
/// delegate. This pointer is only safe in the platform thread.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is only safe to use on the Platform thread, we should probably use a fml::WeakPtr, which will actually do assertions around usage of this, rather than std::weak_ptr which is meant to be thread safe.

Copy link
Contributor Author

@chunhtai chunhtai May 10, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we allowed to depend on fml in this common library? I remember there is a plan to make fml into its own standalone library, but i am not sure whether that is done yet?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhh you're right sorry.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're close to being able to use it, but not quite. Can you add a TODO here?

Copy link
Contributor

@dnfield dnfield left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All that's left for me is using the fml::WeakPtr instead fo std::weak_ptr for a non-thread-safe weak pointer - as long as @stuartmorgan 's concerns are also addressed.

@@ -451,15 +454,17 @@ - (void)setSemanticsEnabled:(BOOL)enabled {
return;
}
_semanticsEnabled = enabled;
// We need to remove the accessibility children from flutter view
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Who is "we" here? (go/avoidwe)

//
// Since we will nil the handler in dealloc. the weakSelf should
// be valid if the handler is ever called.
__unsafe_unretained FlutterTextInputPlugin* weakSelf = self;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't call an unsafe pointer weakSelf; this is the idiomatic name for a weak pointer to self, not an unsafe pointer to self.

// NSTextView does not support _weak reference, so we have to
// use __unsafe_unretained and manage the reference ourselves.
//
// Since we will nil the handler in dealloc. the weakSelf should
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the stray . here please. Also, as above, use something clearer than "we" (e.g., you can just say "|dealloc| removes the handler, so the pointer should be valid if the handler is ever called.").

@@ -176,6 +199,17 @@ - (instancetype)initWithViewController:(FlutterViewController*)viewController {
return self;
}

- (BOOL)isFirstResponder {
if (!self.flutterViewController.viewLoaded || !self.flutterViewController.view.window) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The second half of this check isn't doing anything useful; if self.flutterViewController.view.window is nil, then [self.flutterViewController.view.window firstResponder] is also nil, and thus can't be equal to self.

}

- (instancetype)initWithFlutterView:(FlutterView*)view {
self = [super initWithFrame:view.frame];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking the frame from its child seems confusing. Why are you structuring it this way instead of following the more normal pattern of setting the outermost view's frame and letting subviews resize accordingly?

@@ -278,7 +321,11 @@ - (void)loadView {
}
flutterView = [[FlutterView alloc] initWithMainContext:mainContext reshapeListener:self];
}
self.view = flutterView;
FlutterViewWrapper* nativeTextFieldHidder =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrapperView

self.view = flutterView;
FlutterViewWrapper* nativeTextFieldHidder =
[[FlutterViewWrapper alloc] initWithFlutterView:flutterView];
[nativeTextFieldHidder addSubview:flutterView];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're going to make the Flutter view an init param, why not do this as part of that? Making it possible (and easy) to set up a wrapper that points to a FlutterView that it doesn't actually wrap seems like an anti-feature.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are right.

if (event.type == NSEventTypeKeyDown) {
// macOS only sends keydown for performKeyEquivalent. We need
// to synthesize a key up event. This is true for repeat key
// events.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is this change related to the PR?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And why isn't it tested?

Copy link
Contributor Author

@chunhtai chunhtai May 11, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops I think i missed the test.

This will be called when user press shortcut, e.g. ctrl+v. If the framework receive the keydown without receive the keyup, it will assume the ctrl+v is held and never released. This cause other shortcut to break

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wasn't a problem before because the performKeyEquivalent will only be called if the view is a NSControl

@chunhtai chunhtai force-pushed the issues/77834-real-exp branch from c8d40c2 to 1448ba5 Compare May 11, 2021 23:06
@@ -45,8 +45,8 @@
* This will invoke [delegate flush:] on raster thread and
* [delegate commit:] on platform thread. The requestCommit call will be blocked
* until this is done. This is necessary to ensure that rasterizer won't start
* rasterizing next frame before we flipped the surface, which must be performed
* on platform thread
* rasterizing next frame before the FlutterSurfaceManager flipped the surface,
Copy link
Contributor Author

@chunhtai chunhtai May 11, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I am correct, the FlutterSurfaceManager is in charged of switching context. Let me know if i am wrong

@chunhtai chunhtai force-pushed the issues/77834-real-exp branch 2 times, most recently from 87fea82 to 678480b Compare May 11, 2021 23:58
}

FlutterTextPlatformNode::~FlutterTextPlatformNode() {
[native_text_field_ setFieldEditor:nil];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you give an overview of the ownership relation between the various classes involved here? What object ultimately own the FlutterTextPlatformNode? It's not at all obvious to me what guarantee you have that engine.viewController.textInputPlugin will outlive this object instance.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

You are right, there is no guarantee. I made the change to store the pointer to the engine directly and access the textinputplugin directly from the engine pointer, so it will either be nil or a valid pointer.

@chunhtai chunhtai force-pushed the issues/77834-real-exp branch from 217016e to a018092 Compare May 18, 2021 18:22
if (start > 0 && end > 0) {
selection = NSMakeRange(start, end - start);
} else {
selection = NSMakeRange([self stringValue].length, 0);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the default to set the cursor at the end of the string?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is the native behavior when accessibility focus on a textfield without any selection. The problem with calling becomeFirstResponder on a textfield manually is that it will select the entire string.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setting this will sends the selection back to the framework through the textinputplugin

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sgtm - can you add a comment to that effect?

Copy link
Member

@cbracken cbracken left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM on the approach taken here; most of the comments are nits or questions, or phrased another way -- requests for comments on the bits that are unclear.

@chunhtai chunhtai force-pushed the issues/77834-real-exp branch from 8282fe8 to 5345209 Compare May 26, 2021 22:36
@chunhtai chunhtai requested a review from cbracken May 26, 2021 22:47
@chunhtai chunhtai force-pushed the issues/77834-real-exp branch from a65f9af to 3dac97c Compare June 1, 2021 17:58
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
cla: yes platform-macos waiting for tree to go green This PR is approved and tested, but waiting for the tree to be green to land.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[macOS] voice over does not announce feedback when typing in a text field
5 participants