Skip to content

Add Multi-caret support to TextEdit #61902

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Oct 6, 2022
Merged

Conversation

Paulb23
Copy link
Member

@Paulb23 Paulb23 commented Jun 10, 2022

This PR implements the initial support for multiple carets in TextEdit. Adjusted CodeEdit and the Editor to support them.

This will most likely be the last major feature that I have time to make for 4.0.

Will look at adding multi-caret tests in a separate PR(s), as those will take a little while as this change is quite large.


For the initial support currently it's pretty bare bones, it's enabled by default. You can add an additional caret by Alt + LeftClick. Typing will insert at all carets etc. Caret '0' is the main caret, the viewport will always follow this caret unless selecting via the mouse with a secondary caret. Each caret also has it's own selection.

I've tried to make the behaviour when using multiple carets intuitive based on VSCode, SublimeText and QTCreator, but even between them there are a lot of differences and bugs. Not sure on performance with a lot of carets due to the checks etc but happy to optimise after.

In terms of API changes most of the viewport, caret and edit methods now take in a caret index, the caret index is one of the following:

  • -1 for all carets
  • 0 for the main caret
  • 1-x for the rest

Each method is defaulted as appropriate so existing code should still work (as seen in current tests).

Each method will only accept -1 where it makes sense. For example has_selection(-1) will return true if any caret has a selection. Whereas get_caret_line does not have a strong use case for supporting -1, so is defaulted to 0.

Some utility methods have been exposed for handing multiple carets, they are as follows:
- add_caret - Adds a caret.
- remove_caret - Removes a caret.
- get_caret_count - Gets the number of carets.
- remove_secondary_carets - Removes all additional carets.
- merge_overlapping_carets - Merges any carets and selections that are overlapping. Note this is not called when a caret changes position but after "actions", so if using the API it is possible to get into a state where carets do overlap.
- get_caret_index_edit_order - Returns a list of caret indexes in their edit order, this done from bottom to top. Edit order refers to the way action such as insert_text_at_caret are applied.
- adjust_carets_after_edit - Should be called after editing a caret to reposition the carets affected by the edit such as adding a new line will need to push all carets and selection after that point down a line. This assumes edits are applied in edit order.

Do due a typing now having the potential to consist of multiple edits, the undo redo system would put each caret as a separate step. Using [begin|end]_complex_operation around this would force it into a single step. However this would only make each letter when typing a step, the typing session would not be. We needed a way to have a "hanging" complex op, this comes in the form of an "action".

Calling start_action will end the current action if it is not the same, before starting the new one. end_action marks that the any changes from the action are over, however the action does not end until the timeout or another action is started. This can be done via directly calling start_action, [begin|end]_complex_operation or via a edit that triggers and undo redo step outside of the [start|end]_action calls.

There are currently four supported actions, intended as follow:
- None - No current action.
- Typing - Normal typing
- Backspace - A backwards delete typically using the backspace
- Delete - A forwards delete typically using the delete key.

This fixes some old issues where typing when the caret had a selection would be treated as an entire step, so completion would not work etc. Similarly, pressing the delete key would create an undo step for each letter, now they are treated like typing where multiple deletes are a single action.

Final notes on the undo redo, the system used to use a best guess approach to determine where the caret went, it will now restore the carets exactly as they were. One thing to watch out for, is many carets can spam the undo stack as it becomes difficult to merge them into a single item, as they move about due to other edits, this may cause the undo stack to fill up pretty fast.

Lastly, in order to get this is work, moved the viewport variables outside of the caret and gave them some slightly better names, and currently only the main caret is restored on launch.


closes godotengine/godot-proposals/issues/245
closes godotengine/godot-proposals/issues/3455

@mhilbrunner
Copy link
Member

Editor PR review meeting: Needs review/testing, really nice and wanted feature though

@MewPurPur
Copy link
Contributor

MewPurPur commented Oct 3, 2022

Thanks for making this! I haven't really used mutli-carets on any IDE and I don't have any stylistic prejudices. Also since it's an initial work, I'll not discuss usability.

So here's just a good ol' dump of bugs instead:

  • If the main caret enters some other caret's selection and gets merged with it, the viewport doesn't follow it until there's another movement.
  • When making a mouse selection (Shift + Click), if there are multiple carets that would end up inside the selection, only the first such caret gets destroyed. The other carets can even move a bit before realizing their existence is a mistake, resulting in double-selection :P
  • If two carets are a character apart, Backspace usually makes them align.
  • In the above scenario, if one of the carets is at the beginning of a line, `Backspace sends the other caret at the same column in the previous line.
  • Alt + Up (Move Up) is broken for multiple carets.
  • Alt + F (Fold Line) only works for the main caret.
  • Ctrl+Shift+D (Duplicate Line) makes the first caret duplicate the second caret's line.
  • No one's gonna ever use multi-carets for this, but for completion... Ctrl+Shift+E (Evaluate Expression) puts every evaluated value at each caret.

@Paulb23
Copy link
Member Author

Paulb23 commented Oct 3, 2022

Rebased and thanks for testing, hopefully fixed most of those issues.

Regarding Fold Line and Evaluate Expression for initial support I think that's okay, can enhance them later.

@akien-mga akien-mga requested review from bruvzg and KoBeWi October 4, 2022 08:51
Comment on lines 1174 to 1179
Vector<int> cursor_columns;
for (int c = 0; c < text_editor->get_caret_count(); c++) {
cursor_columns.push_back(text_editor->get_caret_column(c));
}
Copy link
Member

Choose a reason for hiding this comment

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

Same.

@KoBeWi
Copy link
Member

KoBeWi commented Oct 4, 2022

Should paste behave this way?
godot windows editor dev x86_64_Q663tua9wY
VS Code for comparison
Code_VFITx22ibF

remove_text(get_caret_line(), get_caret_column(), get_caret_line(), get_caret_column() + 1);
// Remove the old character if in overtype mode and no selection.
if (is_overtype_mode_enabled() && !had_selection) {
/* Make sure we don't try and remove empty space. */
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
/* Make sure we don't try and remove empty space. */
// Make sure we don't try and remove empty space.

Comment on lines 841 to 842
/* This value informs us by how much we changed selection position by indenting right. */
/* Default is 1 for tab indentation. */
Copy link
Member

@KoBeWi KoBeWi Oct 4, 2022

Choose a reason for hiding this comment

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

Suggested change
/* This value informs us by how much we changed selection position by indenting right. */
/* Default is 1 for tab indentation. */
// This value informs us by how much we changed selection position by indenting right.
// Default is 1 for tab indentation.

This comment style is just used everywhere in the codebase.

Same below.

set_caret_line(caret.line - 1);
set_caret_column(text[caret.line].length());
for (int i = 0; i < carets.size(); i++) {
// Handle selection
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// Handle selection
// Handle selection.

Could as well fix the comments if they are being moved.

@KoBeWi
Copy link
Member

KoBeWi commented Oct 4, 2022

Ah right

In terms of API changes most of the viewport, caret and edit methods now take in a caret index, the caret index is one of the following:

-1 for all carets
0 for the main caret
1-x for the rest

This should be mentioned somewhere in the docs, probably in the class description or caret_multiple description.

String clipboard = text[cl];
DisplayServer::get_singleton()->clipboard_set(clipboard);
set_caret_column(0);
// Check for overlaping carrets
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// Check for overlaping carrets
// Check for overlaping carrots

jk

@@ -42,6 +42,14 @@ class TextEdit : public Control {
GDCLASS(TextEdit, Control);

public:
/* Edit Actions. */
Copy link
Member

Choose a reason for hiding this comment

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

Unnecessary comment.

...although I see there are similar comments below :/
It only repeats enum name.

@@ -361,19 +377,39 @@ class TextEdit : public Control {
int _get_char_pos_for_line(int p_px, int p_line, int p_wrap_index = 0) const;

/* Caret. */
Copy link
Member

Choose a reason for hiding this comment

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

I think this comment is out of place now, no?

@KoBeWi
Copy link
Member

KoBeWi commented Oct 4, 2022

I checked through the code and it looks ok, I think. ~80% is just moving things around and adding caret index. Can't say much about the feature implementation, because it's rather complex, but I assume it's good, because it works xdd

I left some comments, mostly some useless bikeshedding about comments. We use // for comments, but I see lots of /* */ here, which isn't even used consistently. I think with that much code moved, we could take the chance to fix some of them.

From more important stuff, there is this weird paste behavior I mentioned above. Although I used VS Code as reference, so not sure how it works in other editors. #61902 (comment) is rather important too, because the PR just adds caret_index arguments everywhere without explaining what they do.

I don't use multiple carets much, but I think a nice-to-have feature would be rectangular selection:
Code_FLGopPWzxP
It can be implemented in another PR.

@EricEzaM
Copy link
Contributor

EricEzaM commented Oct 5, 2022

Rider has the same behaviour as VS code, I think it is the expected behaviour.

rider64_KWbv4gh2aZ.mp4

@Paulb23
Copy link
Member Author

Paulb23 commented Oct 5, 2022

Pasting should now work like VSCode, and updated the class description to have caret_index argument details.

@akien-mga akien-mga merged commit f590321 into godotengine:master Oct 6, 2022
@akien-mga
Copy link
Member

Thanks!

@bruvzg
Copy link
Member

bruvzg commented Oct 6, 2022

It's an edge case, but might need some extra indication (I do not know what exactly will be appropriate here):

  • If you have LTR segment in the RTL text (or RTL segment in LTR text), and two carets are on the both edges of it, both split carets are drawn on top of each other. So it's indistinguishable from singe caret on the segment edge.
Screen.Recording.2022-10-06.at.10.04.07.mov

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
9 participants