flutter / flutter

Flutter makes it easy and fast to build beautiful apps for mobile and beyond
https://flutter.dev
BSD 3-Clause "New" or "Revised" License
166.12k stars 27.43k forks source link

[proposal][desktop] Tab character (\t) should be displayed as indentation in TextField #77512

Open aldrinmathew opened 3 years ago

aldrinmathew commented 3 years ago

Tab Character \t should be displayed as indentation in EditableText, TextField and all implementations of Text widgets in Desktop

This is a common behaviour in many desktop applications

Flutter Channel: master Platform: Desktop, Linux Version: 2.1.0-11.0.pre.138

When Tab key is pressed while the TextField is focused, in a Flutter Desktop Application, by default, the focused TextField is unfocused and the focus shifts to another widget. That is a problem in my case as I want the user to be able to enter tab characters. But I partially solved it by using a RawKeyboardListener to detect when the user presses Tab key, and then inserted a \t to the TextField at the current cursor position through its controller. The problem is that, the \t character is displayed as a small space, almost half as wide as whitespace, which is counterproductive. There should be an option to configure how \t is displayed, as it can be of multiple number of spaces' width, and it should be customisable.

I believe this is not exactly a bug. For mobile devices, the Tab key is irrelevant. For web, pressing Tab key is helpful in shifting focus between UI elements. But for desktop, it makes sense to have an option to customise how a Tab character is displayed in a TextField.

Suggestion

Add an option to disable focus shifting on Tab press on Desktop and add Tab character to the TextField. Provide a way to customise Tab character display, by equating it to a specific number of whitespaces that can be changed if required.

pedromassangocode commented 3 years ago

Yes this is definitely a proposal. Just tested on Slack MacOS app and Tab button move focus to another component.

TimWhiting commented 3 years ago

Found a way to accomplish this:

But I agree that there should be two properties on EditableText and TextField called something like: bool acceptTabs int tabIndentation And TextField or Editable text should handle wrapping in the Action/ Shortcuts widget.

code sample ```dart import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData.dark(), home: MyHomePage(title: 'Flutter Demo Home Page'), ); } } class MyHomePage extends StatefulWidget { MyHomePage({Key key, this.title}) : super(key: key); final String title; @override _MyHomePageState createState() => _MyHomePageState(); } class InsertTabIntent extends Intent { const InsertTabIntent(this.numSpaces, this.textController); final int numSpaces; final TextEditingController textController; } class InsertTabAction extends Action { @override Object invoke(covariant Intent intent) { if (intent is InsertTabIntent) { final oldValue = intent.textController.value; final newComposing = TextRange.collapsed(oldValue.composing.start); final newSelection = TextSelection.collapsed( offset: oldValue.selection.start + intent.numSpaces); final newText = StringBuffer(oldValue.selection.isValid ? oldValue.selection.textBefore(oldValue.text) : oldValue.text); for (var i = 0; i < intent.numSpaces; i++) { newText.write(' '); } newText.write(oldValue.selection.isValid ? oldValue.selection.textAfter(oldValue.text) : ''); intent.textController.value = intent.textController.value.copyWith( composing: newComposing, text: newText.toString(), selection: newSelection, ); } return ''; } } class _MyHomePageState extends State { TextEditingController textController; @override void initState() { super.initState(); textController = TextEditingController(); } @override void dispose() { textController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(widget.title)), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Actions( actions: {InsertTabIntent: InsertTabAction()}, child: Shortcuts( shortcuts: { LogicalKeySet(LogicalKeyboardKey.tab): InsertTabIntent(2, textController) }, child: TextField( controller: textController, textInputAction: TextInputAction.newline, maxLines: 30, keyboardType: TextInputType.multiline, ), ), ), ], ), ), ); } } ```
aldrinmathew commented 3 years ago

@TimWhiting @pedromassangocode

I tested the implementation by Tim Whiting and it works well. :+1:

However, I reviewed the code and found that it is adding the appropriate number of spaces whenever the user presses Tab key. I have already implemented this in the application that I am developing. Is there anyway that we can add the '\t' character in the text and make it look like multiple spaces? I mean, without replacing the tab with spaces? In theory, we should be able to configure and customise the display of certain characters in the text field. Especially when we consider the possibility that desktop applications developed by flutter might not always be mere "applications", they might be complex enough to be called "software".

Use case

I want the user to be able to add the raw tab character (\t) to the text field. And I want to customise the display of the tab character to resemble the equivalent number of spaces chosen by the user. I am developing a text/code editor. I think you get where I am going with this. If a user/programmer presses tab to indent their content/code, and if I write code to replace the indentation with actual whitespace, someone's definitely getting angry. I can easily provide them with an option to replace '\t' with spaces, but that shouldn't be the default behaviour in this case.

aldrinmathew commented 3 years ago

@TimWhiting

This is the modified code. This won't run as there is no property called indentSpaceCount for TextField.

code sample ```dart import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData.dark(), home: MyHomePage(title: 'Flutter Demo Home Page'), ); } } class MyHomePage extends StatefulWidget { MyHomePage({Key key, this.title}) : super(key: key); final String title; @override _MyHomePageState createState() => _MyHomePageState(); } class InsertTabIntent extends Intent { const InsertTabIntent(this.textController); final TextEditingController textController; } class InsertTabAction extends Action { @override Object invoke(covariant Intent intent) { if (intent is InsertTabIntent) { final oldValue = intent.textController.value; final newComposing = TextRange.collapsed(oldValue.composing.start); final newSelection = TextSelection.collapsed( offset: oldValue.selection.start + 1); final newText = StringBuffer(oldValue.selection.isValid ? oldValue.selection.textBefore(oldValue.text) : oldValue.text); newText.write('\t'); newText.write(oldValue.selection.isValid ? oldValue.selection.textAfter(oldValue.text) : ''); intent.textController.value = intent.textController.value.copyWith( composing: newComposing, text: newText.toString(), selection: newSelection, ); } return ''; } } class _MyHomePageState extends State { TextEditingController textController; @override void initState() { super.initState(); textController = TextEditingController(); } @override void dispose() { textController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(widget.title)), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Actions( actions: {InsertTabIntent: InsertTabAction()}, child: Shortcuts( shortcuts: { LogicalKeySet(LogicalKeyboardKey.tab): InsertTabIntent(textController) }, child: TextField( controller: textController, textInputAction: TextInputAction.newline, maxLines: 30, keyboardType: TextInputType.multiline, indentSpaceCount: 4, ), ), ), ], ), ), ); } } ```
TimWhiting commented 3 years ago

Yeah, I agree, the properties should be more like:

As a design note, for accessibility there needs to be a way to unfocus the field that accepts tabs to a larger focus scope to allow moving to a different field. I believe this is typically done by the escape key, but I could be wrong.

aldrinmathew commented 3 years ago

@TimWhiting

Exactly, this model looks perfect. And yes, Escape key is usually the way to do it.

TimWhiting commented 3 years ago

Thinking a bit more:

The indentSpaceCount should probably be in the TextStyle instead. Which can be specified application wide.

And replaceTabsWithSpaces should not be a property and users should use a TextInputFormatter instead to replace \t with whatever so that users can do anything (for example code editors should indent the start of the line the user pressed \t on, and not at the location they pressed it, same for bulleted lists).

So the TextInputFormatters that should be added would be: IndentLineFormatter() --> replaces a \t anywhere in the line with a \t at the beginning of the line ReplaceTabFormatter(int numSpaces) --> replaces a \t with numSpaces of plain spaces Which could be chained to provide both.

Also I forgot in my implementation to add a Shift-tab which would need a DedentLineFormatter(int numSpaces) --> replace a \t or numSpaces at the beginning of the line with ''

Edit: But I guess this is where the TextInputFormatters break down, because any time it formats it will remove another \t or spaces until everything is dedented completely. So maybe the Shortcuts / Action framework is really the better fit for handling tab / shift-tab. But we could still use TextInputFormatters to do the job to allow for flexibility for the user to change tabs to whatever they want. But instead of have them in the list of general TextInputFormatters, they should be two properties:

or:

gspencergoog commented 3 years ago

I agree that there should be something like a tabWidth in the TextStyle, that controls how wide a tab is drawn when rendered, the units should probably be in multiples of the em space of the font. There should be a property on the text field that says whether it handles tabs or not. Replacing tabs with spaces is probably something that should be done after the text is received by the app, since that's highly dependent on the use case (a rich text editor would want tab stops, a code editor might want to start at a particular initial indent before replacing tabs with spaces, etc.)

Note that if a text field handles tabs, then the app is no longer traversable via the keyboard, since as soon as a text field gets focus, it will not allow the focus to leave again when the user presses tab. This is bad for accessibility on all platforms (including mobile platforms), which is why the current implementation always interprets tabs as focus change events. @TimWhiting in your usage of the escape key to enable this, what is the behavior? Does it just jump to the next control? Does Shift-Esc do anything (like go to the previous control)?

We aspire to someday be able to allow people to easily write a code or rich text editor in Flutter, but we're definitely not there yet, and this is one of the areas where we're missing functionality.

I've thought before that there should also be some way to configure all of the text fields in a widget subtree based on the TextInputType of the field (and maybe allow defining user types for text fields), which would be useful in configuring more complex apps for things like this.

cc @HansMuller

aldrinmathew commented 3 years ago

@gspencergoog

I have implemented many major features required for a basic code editor in my application. The application can autosave files and create new files. it supports basic auto-completion. It starts with Dark Mode as the default colour scheme. It can generate a Syntax Tree for the code as the user types. Git support, Opening files within app and custom terminal commands is in the prototype phase, and custom file save is already implemented. I have even devised a logic to implement a custom file browser, since the default flutter package is not supported in all platforms. All of these features are using just Dart and Flutter. No other programming language is used. The app is in private development for now, and it will be released to the public once all major features are implemented and tested. Handling of the Tab character is the only important thing that I find to be missing right now. If there already is a way to implement this using TextInputFormatter, that will be great. As far as I know, there isn't, but may be I haven't looked in the right place. As of now, all I need is to control the display of the \t character. In theory, the TextInputFormatter should be able to handle it, as it seems like it was made for purposes like this.

aldrinmathew commented 3 years ago

@TimWhiting Is there anyway to implement this without the use of a predefined Formatter? Does the TextInputFormatter support low-level customisation like that?

TimWhiting commented 3 years ago

According to the TextInputFormatter docs: A TextInputFormatter can be optionally injected into an EditableText to provide as-you-type validation and formatting of the text being edited.

So it can format / allow characters / disallow characters, alter text (replace \t with two / four spaces). But it is not meant for controlling how characters are displayed. As @gspencergoog mentioned, visual tab width for display should probably be a property on TextStyle, but is not possible now. I suspect that will involve some engine work.

aldrinmathew commented 3 years ago

@TimWhiting

Oh ok then. Thanks for the helpful response. It seems like something like this won't be implemented in the flutter engine for a long time. So for now and the foreseeable future, Tab characters in the TextField in my application will look like half-spaces. That's really counterproductive. And unfortunate. At least, it will still be Tab characters when the user saves the file.

TimWhiting commented 3 years ago

You might be able to create some sort of workaround for now without engine changes, but it will probably be more complex. I'm really not sure what is required to actually display tabs better, maybe the engine is not actually involved.

aldrinmathew commented 3 years ago

@TimWhiting

I had a brief look at: https://pub.dev/packages/flutter_multi_formatter

and its Github Repository: https://github.com/caseyryan/flutter_multi_formatter

But it seems to be a predefined static spacing between two characters and doesn't seem to be dynamic.

TimWhiting commented 3 years ago

@AldrinMathew Yeah formatters aren't what you are looking for:

@gspencergoog I've implemented two approaches to the tab character / tab focus problem:

Approach 1: Escape to exit entering tabs into the textfield and move to next field. Shift escape to exit entering tabs and move to previous field Approach 2: Tabs aren't automatically captured when textfield becomes focused until user presses enter, escape exits the tab entering mode but still has focus on the textfield, as a part of the regular tab order. (There is an 'outer' and 'inner' hierarchical focus, that you navigate using enter / escape).

2 seems like the better approach and feels more natural since you can tab right past the field without automatically getting captured. Also probably fits best with voice/accessibility interfaces. The shift-escape-tab solution feels awkward, but tabbing to the previous field is probably less used.

Here is the code: https://github.com/TimWhiting/test_app

And the demo app on github pages: https://timwhiting.github.io/test_app/#/

aldrinmathew commented 3 years ago

@TimWhiting

I tested the Test App and the second implementation (Escape and Enter to exit and enter TabScopes) makes more sense. It is not much of hindrance to accessibility, and changes focus only when required and not automatically.

aldrinmathew commented 3 years ago

@TimWhiting

The current implementation in my code editor changes the focus every time the user presses Tab key and only refocuses on a second key press. Do I have the permission to use the implementation of Shortcuts & IndentationAction in your Test App in my application?

TimWhiting commented 3 years ago

Yeah you have permission. It's meant to be a sample to help solve this issue and determine what the desired behavior is. There might be better ways of doing it. Hopefully something like it will be integrated into the framework someday.

TimWhiting commented 3 years ago

Another conflicting use-case of Tab in text fields is for tab auto-complete. It seems to me that this can be distinguished from a insert of a literal tab by first checking if there is a suggestion available and the selection is empty, if so, then the first suggestion should be applied. If there is no suggestion available or the selection is not empty than the tab should be taken as a literal tab.

TimWhiting commented 3 years ago

I've created a design doc here: https://docs.google.com/document/d/1aHucsI0NWGWu2Dm_XFsBLxiTgJRM8h2XBB7PVAnVxlU/edit?usp=sharing&resourcekey=0-zLbXFlP_A2e_Yoi43vdiiw

Appli-chic commented 3 years ago

Any news about this topic?

justinmc commented 3 years ago

@TimWhiting The design doc looks like it's on the right track to me, are you still working on bringing it to Flutter? Happy to help if needed.

TimWhiting commented 3 years ago

@justinmc I was waiting for feedback on the design doc and for all of the changes around TextEditing / Shortcuts / Actions that were happening at the time, and since then I have had some other priorities. I'm busy with a bunch of stuff right now for my Master's Thesis, so would welcome any contribution or help you have to offer. If you want to fold these changes into your text editing refactor that would be great!

justinmc commented 3 years ago

Sorry I thought I had already replied here. I will see if I can get this in as part of my current text editing work. Thanks for the great design doc and good luck on your thesis!

flutter-triage-bot[bot] commented 4 months ago

The triaged-desktop label is irrelevant if there is no team-desktop label or fyi-desktop label.