Skip to content
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

Adding support for selecting text with shift + movement #989

Merged
merged 1 commit into from Nov 8, 2019

Conversation

scoennz
Copy link
Contributor

@scoennz scoennz commented Oct 24, 2019

This is a proposal for an implementation of text selection using shift + cursor movement as is customary in many GUI. This refers to issue #979.

The implementation is based on defining a new boolean flag shift_mode in the SelectionState class. shift_mode is initialized to False by the class constructor and set to True by a call to SelectionState. enter_shift_mode() whenever a shift+movement binding is called to start selecting text in this fashion. There is an associated new shift_selection_mode filter. The advantage of proceeding this way are that

  1. the shift_mode flag is automatically destroyed when selection is exited by any means through any other bindings (because the SelectionState instance is destroyed). In that way, we are sure that the shift_mode flag will not persist to True by accident.
  2. it enables the shift-selection mode to co-exist for example with the Emacs marking mode (entered with ctrl-space). In principle, it could also co-exist with the vi-mode, but I haven't tested that as I am really not familiar with vi - so at the moment the corresponding binding definitions are only defined alongside the Emacs bindings, though they could in principle be defined with the basics bindings with some extra testing and tweaks.

Behavior:

  • Shift + any cursor motion starts or extend the selection, accompanied by the corresponding cursor movement.
  • A cursor movement without shift will exit the selection (and moves the cursor).
  • The selection is replaced by any text that is typed (including enter, if multiline; backspace, which deletes the selection; and pasting/yanking - note: more tests might be needed here especially with regard to multiline paste).

Cursor movements above refers to left, right, up, down, home & end (beginning & end of line), ctrl-left/right (word-by-word), ctrl-home/end (beginning/end of buffer). All missing shift- combinations of these keys have been added to keys.py (and set by default to do nothing in bindings/basic.py). Ctrl-home/end have also been added, with the corresponding commands implemented as new readline-style commands in named_commands.py. For completeness, ctrl/shift-insert keys have also been added as these are sometimes used for copy/paste. All these new key combinations are correctly converted to key-presses in input/win32.py.

I have tested this implementation stand-alone as well as in IPython 7.6.1 (through applying the same changes to prompt_toolkit 2.0.9 - apart from removing the type hints). Everything seems to work well, without any changes required to the IPython source code itself.

Happy to get some feedback at this stage, of what could be improved or done differently. I will continue to do more testing with multiline pasting in the meantime.

@asmeurer
Copy link
Contributor

I haven't done a close review of this but it sounds similar to how I implemented it in my own project (search https://github.com/asmeurer/mypython/blob/860f212d341956bf39cb0ee75db3248ffbfc1615/mypython/keys.py for "shift_arrow").

@scoennz
Copy link
Contributor Author

scoennz commented Oct 25, 2019

Hi, asmeurer. Indeed, same sort of logic as yours.

@asmeurer
Copy link
Contributor

I just tested this and it seems to have the same sort of behavior that I have been using. The only difference I noticed is that I made it in mine so that if there is a selection, pressing "up" cancels the selection without going back in history. I'm not really sure why I did that though and I'm not sure if it really makes sense.

@asmeurer
Copy link
Contributor

I wasn't able to test this directly on my prompt application because it doesn't work with prompt-toolkit 3.0 yet. Is 3.0 going to be another big backwards incompatible release?

@asmeurer
Copy link
Contributor

Also I never noticed until now that Shift-Arrow doesn't work by default in my Linux terminal (Konsole) or the default macOS Terminal. It works in iTerm2, but I can't remember if that's out of the box or if I configured it.

@scoennz
Copy link
Contributor Author

scoennz commented Oct 26, 2019

I can easily port my implementation to the 2.0.9 branch. I'll do a separate branch for that.

@jonathanslenders
Copy link
Member

jonathanslenders commented Oct 28, 2019

@scoennz,

I prefer to not backport this change to 2.0, and have new features only on the 3.0 branch.
Bug fixes are more than welcome on the 2.0 branch. Hope that's fine for you. It's mainly a matter of limiting the maintenance work.

The 3.0 branch is for 90% compatible with 2.0. The only change is that it uses asyncio natively and requires Python 3.6 because of this (async-for requirement). If really important to many people we can probably make it run on Python 3.5. Many applications don't require any changes for the 3.0 branch. IPython requires a few, but not much: https://github.com/ipython/ipython/pull/11726/files

edit: I have not had the chance yet to review the changes in this PR, but hope to do soon.

@@ -139,6 +144,11 @@ def is_returnable() -> bool:
handle('escape', 'enter', filter=insert_mode & is_returnable)(
get_by_name('accept-line'))

# It appears that on windows, Enter -> ^M but Ctrl-Enter brings in a ^J;
# We handle this the same as escape-enter
handle('escape', 'c-j', filter=insert_mode & is_returnable)(
Copy link
Member

Choose a reason for hiding this comment

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

This is probably due to a bug in input/win32.py. It's supposed to be 'escape', 'c-m' too.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, I wasn't too sure how to address this, and didn't want to break things by modifying `input/win32.py' incorrectly. There seems to be some inconsistencies in the code. The issue is that on windows,

  • enter brings '\r' - ascii 13 - 'c-m'
  • ctrl-enter brings '\n' - ascii 10 - 'c-j' and the CTRL_PRESSED flag

input/win32.py converts CTRL_PRESSED 'c-j' (which is ctrl-enter) into 'escape', 'c-j', but then that is NOT handled by the 'escape','enter' binding, because 'enter' is 'c-m' (issue 1).

Also earlier in input/win32.py, we find this piece of code:

if self.mappings[ascii_char] == Keys.ControlJ:
    u_char = '\n'  # Windows sends \n, turn into \r for unix compatibility.
result = KeyPress(self.mappings[ascii_char], u_char)

The comment seems to tell the correct story, but then that is not what is done. u_char should be set to '\r' here (setting it to '\n' doesn't do anything; it is already that) and then we should call again ascii_char = u_char.encode('utf-8') so that in the next line (result = ...) we get KeyPress('c-m', '\r'). At the moment, hitting ctrl-enter still leaves us with a 'c-j' (issue 2). On top of that fix then, we have to fix issue 1 above: it should be CTRL_PRESSED 'c-m' that is converted into 'escape', 'c-m' rather than CTRL_PRESSED 'c-j.'

I can see this would work, but then wasn't sure if this wouldn't break anything else. Given the importance of the enter key, I didn't want to mess it up. Should I fix that as part of this pull request, or should I make it a separate pull request?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Have actually tried the solution I propose above, but for some reason it does not work. When hitting ctrl-enter, the prompt displays ^[ and then insert a carrier return (adds a line). Like the two keys, 'escape','c-m', are treated separately. Note that when I hit ctrl-Q (to display escape sequences) and then ctrl-enter, it does display ^[^M, so the c-j is correctly converted to c-m, but somehow something else happens after that ...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maybe there is a problem with the KEY_ALIASES mechanism. Like the 'escape','c-m' is not actually converted into 'escape','enter' ... Just my 5c here, not too sure when this is called.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Got a bit deeper in this issue here. The problem seems to be that 'escape','c-m' is turned into a BracketedPaste event because win32.ConsoleInputReader._is_paste returns true for that key combination ('escape' is considered str and 'c-m' is a newline.) Not sure what to do here. Might be simpler to pass ctrl-enter as 'escape','c-j' and binds to that rather than tamper with the paste mechanism? Or even define a 'c-enter' key, and not translate it into an `escape' combination at all?

Copy link
Member

Choose a reason for hiding this comment

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

Right. Thanks for taking the time to dig into this issue.
I'm not sure yet what's the best approach. The _is_paste method is a workaround, because the Windows console doesn't support bracketed paste, but it's important to detect it.
Probably we can simply ignore the escape key in here, and it should be fine.

The chance that people literally paste "escape + enter" and expect it to be inserted as text literally like that is very small...

@@ -234,6 +244,133 @@ def _(event: E) -> None:
"""
event.current_buffer.exit_selection()

def unshift_move(event: E):
Copy link
Member

Choose a reason for hiding this comment

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

This needs a return annotation -> None: in order to tell mypy to typecheck this function.

Copy link
Member

Choose a reason for hiding this comment

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

Can you add a docstring too for this function?

@@ -42,6 +42,10 @@ def __init__(self, original_cursor_position: int = 0,

self.original_cursor_position = original_cursor_position
self.type = type
self.shift_mode = False
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 attribute needs to go into key_bindings.emacs_state.EmacsState, because it's specific to the Emacs key bindings.

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's what I had initially done, but then you need to make sure that that flag is set back to False when the selection is removed by any existing or future keybinding. So either that means adding stuff in quite a few key bindings, or handling this in buffer.py, wherever selection_state is set to None (in exit_selection and copy_selection and maybe somewhere else). That's why I prefer having the shift_mode flag in selection_state, since it then becomes automatic. Also, having it in selection_state opens the way to extending the shift-selection mode to maybe the vi mode (or any other future mode?) in the future? See also my two points at the top of my initial pull request description. What do you think?

Copy link
Member

Choose a reason for hiding this comment

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

That makes sense. Thanks! Just keep it.

command = 'beginning-of-buffer'
if key == Keys.ShiftControlEnd:
command = 'end-of-buffer'
get_by_name(command)(event)
Copy link
Member

Choose a reason for hiding this comment

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

Keep in mind that command is potentially undefined. This can raise a NameError.

I think I'd prefer a lookup table that maps the keys to the commands. Something like this:

if key == Keys.ShiftUp:
    ...
    return
...
key_to_command = {
     Keys.ShiftHome: 'beginning-of-line',
     Keys.ShiftEnd: 'end-of-line',
     ...
}
try:
    # Both the dict lookup and `get_by_name` can raise KeyError.
    handler = get_by_name(key_to_command[key])
except KeyError:
    pass
else:  # (`else` is not really needed here.)
    handler(event)

@jonathanslenders
Copy link
Member

@scoennz,

This looks very nice actually. I have a few remarks (see comments).
I'm also thinking that it could be a good idea to move these key bindings into a separate function, similar to load_emacs_search_bindings. This would then be load_emacs_shift_selection_bindings or something like that. It should then be included in key_bindings.defaults.load_key_bindings.

@asmeurer
Copy link
Contributor

@jonathanslenders will you be making another porting guide? I guess there are other changes. It seems my method of adding custom keys no longer works. I haven't had a chance to look into it. How long will it be until 3.0 is released?

@jonathanslenders
Copy link
Member

jonathanslenders commented Oct 30, 2019

Yes, there is an upgrade guide in the works: https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/docs/pages/upgrading/3.0.rst I have to add a few more things. I'm not sure what changed to the key bindings.

I really hope to release soon, definitely before the end of 2019. I prefer not to hurry, because I really don't like to do breaking changes after the release and want to make sure it's stable. (Right now, I think stabliity is already fine though.)

@asmeurer
Copy link
Contributor

Thanks. I'll try to port my code soon so that I can report any issues before the release.

And sorry for hijacking this pull request discussion. I'll open new issues for any issues I come across.

@scoennz
Copy link
Contributor Author

scoennz commented Oct 31, 2019

Hi Jonhatan, I have implemented your suggestions. The only remaining issue is the 'escape', 'c-j' thing. I have commented on that above. Let me know how you want to proceed.

@jonathanslenders
Copy link
Member

Thanks a lot for this @scoennz, for the changes! I really appreciate it.
I hope to review it tomorrow or this weekend (no time today unfortunately).

if result.key == Keys.End:
result.key = Keys.ShiftControlEnd

# Correctly handle Control-Arrow/Home/End and Control-Insert keys.
if (ev.ControlKeyState & self.LEFT_CTRL_PRESSED or
Copy link
Member

Choose a reason for hiding this comment

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

I wonder whether this doesn't have to be an elif? If both shift and control are pressed don't we end up in both this branch and the if` above? I think we replace Keys.ShiftControlLeftbyControlLeft`` like this. (Maybe I'm missing something.)

Copy link
Member

Choose a reason for hiding this comment

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

Never mind. The assignment to result.key ensures that none of the other conditions satisfy. This is fine.

@@ -139,6 +144,11 @@ def is_returnable() -> bool:
handle('escape', 'enter', filter=insert_mode & is_returnable)(
get_by_name('accept-line'))

# It appears that on windows, Enter -> ^M but Ctrl-Enter brings in a ^J;
# We handle this the same as escape-enter
handle('escape', 'c-j', filter=insert_mode & is_returnable)(
Copy link
Member

Choose a reason for hiding this comment

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

Right. Thanks for taking the time to dig into this issue.
I'm not sure yet what's the best approach. The _is_paste method is a workaround, because the Windows console doesn't support bracketed paste, but it's important to detect it.
Probably we can simply ignore the escape key in here, and it should be fine.

The chance that people literally paste "escape + enter" and expect it to be inserted as text literally like that is very small...

@jonathanslenders
Copy link
Member

Hi @scoennz,

If you squash the commits into one, I'll merge it.
The bracketed-paste bug can be fixed in a separate PR.

Thanks a lot for this. I just tried on both "tmux in mintty on WSL" and on the Windows console, and it works perfect out of the box.

This refers to issue prompt-toolkit#979.

Text can be selected and the selection extended with
shift + cursor movement (`left`, `right`, `up`, `down`, `home`, `end`,
`ctrl-left`, `ctrl-right`, `ctrl-home`, `ctrl-end`).

For completeness, `ctrl-home` and `ctrl-end` bindings (beginning and
end of buffer) are also added, through new readline style commands
(`'beginning-of-buffer'` and `'end-of-buffer'`).

The selection is replaced by any text that is typed (including
pasting/yanking, and enter, if multi-line).

A cursor movement without shift while in selection mode cancels
the selection.

This works alongside the standard Emacs marking mode (started with
`Ctrl-space`). The difference is handled by adding a `shift_mode`
boolean in the `SelectionState` (set by the `enter_shift_mode()`
method) and a corresponding `shift_selection_mode filter`.

All the related bindings are defined in a new
`load_emacs_shift_selection_bindings()` function, now called
in `key_binding.defaults.load_key_bindings`.

Other changes include
* adding the required keys in `keys.py`
* processing them properly
  in `input.win32.ConsoleInputReader._event_to_key_presses`
* defining default empty handlers in `key_binding.bindings.basic.py`
@scoennz
Copy link
Contributor Author

scoennz commented Nov 7, 2019

Hi Jonathan, I have removed the 'escape','c-j' binding (to be dealt with separately) and squashed everything together. This is now ready to go.

handler = get_by_name(key_to_command[key])
except KeyError:
pass
else: # (`else` is not really needed here.)
Copy link
Member

Choose a reason for hiding this comment

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

Actually, else is needed here, because we don't want handler to execute if there was a KeyError.
(I can remove the comment in a separate commit.)

@jonathanslenders jonathanslenders merged commit 9d7e5c8 into prompt-toolkit:master Nov 8, 2019
@jonathanslenders
Copy link
Member

Merged! Thank you @scoennz, for taking the time for this. I really appreciate it.

@scoennz scoennz deleted the shift-selection branch November 10, 2019 20:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants