Skip to content
19 changes: 18 additions & 1 deletion prompt_toolkit/buffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,16 @@
import tempfile

__all__ = (
'EditReadOnlyBuffer',
'AcceptAction',
'Buffer',
'indent',
'unindent',
)

class EditReadOnlyBuffer(Exception):
" Attempt editing of read-only buffer. "


class AcceptAction(object):
"""
Expand Down Expand Up @@ -169,13 +173,14 @@ class Buffer(object):
def __init__(self, completer=None, history=None, validator=None, tempfile_suffix='',
is_multiline=Never(), complete_while_typing=Never(),
enable_history_search=Never(), initial_document=None,
accept_action=AcceptAction.RETURN_DOCUMENT,
accept_action=AcceptAction.RETURN_DOCUMENT, read_only=False,
on_text_changed=None, on_text_insert=None, on_cursor_position_changed=None):

# Accept both filters and booleans as input.
enable_history_search = to_simple_filter(enable_history_search)
is_multiline = to_simple_filter(is_multiline)
complete_while_typing = to_simple_filter(complete_while_typing)
read_only = to_simple_filter(read_only)

# Validate input.
assert completer is None or isinstance(completer, Completer)
Expand All @@ -193,6 +198,7 @@ def __init__(self, completer=None, history=None, validator=None, tempfile_suffix
self.is_multiline = is_multiline
self.complete_while_typing = complete_while_typing
self.enable_history_search = enable_history_search
self.read_only = read_only

#: The command buffer history.
# Note that we shouldn't use a lazy 'or' here. bool(history) could be
Expand Down Expand Up @@ -277,6 +283,10 @@ def text(self, value):
assert isinstance(value, six.text_type), 'Got %r' % value
assert self.cursor_position <= len(value)

# Don't allow editing of read-only buffers.
if self.read_only():
raise EditReadOnlyBuffer()

changed = self._set_text(value)

if changed:
Expand Down Expand Up @@ -350,6 +360,10 @@ def document(self, value):
"""
assert isinstance(value, Document)

# Don't allow editing of read-only buffers.
if self.read_only():
raise EditReadOnlyBuffer()

# Set text and cursor position first.
text_changed = self._set_text(value.text)
cursor_position_changed = self._set_cursor_position(value.cursor_position)
Expand Down Expand Up @@ -1001,6 +1015,9 @@ def open_in_editor(self):
"""
Open code in editor.
"""
if self.read_only():
raise EditReadOnlyBuffer()

# Write to temporary file
descriptor, filename = tempfile.mkstemp(self.tempfile_suffix)
os.write(descriptor, self.text.encode('utf-8'))
Expand Down
7 changes: 7 additions & 0 deletions prompt_toolkit/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,10 @@ class IncrementalSearchDirection:

#: Name of the system buffer.
SYSTEM_BUFFER = 'system'

# Dummy buffer. This is the buffer returned by
# `CommandLineInterface.current_buffer` when the top of the `FocusStack` is
# `None`. This could be the case when there is some widget has the focus and no
# actual text editing is possible. This buffer should also never be displayed.
# (It will never contain any actual text.)
DUMMY_BUFFER = 'dummy'
14 changes: 13 additions & 1 deletion prompt_toolkit/filters/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
'IsAborting',
'IsDone',
'IsMultiline',
'IsReadOnly',
'IsReturning',
'RendererHeightIsKnown',
)
Expand All @@ -38,7 +39,7 @@ class HasSelection(Filter):
Enable when the current buffer has a selection.
"""
def __call__(self, cli):
return bool(cli.buffers[cli.focus_stack.current].selection_state)
return bool(cli.current_buffer.selection_state)

def __repr__(self):
return 'HasSelection()'
Expand Down Expand Up @@ -66,6 +67,17 @@ def __repr__(self):
return 'IsMultiline()'


class IsReadOnly(Filter):
"""
True when the current buffer is read only.
"""
def __call__(self, cli):
return cli.current_buffer.read_only()

def __repr__(self):
return 'IsReadOnly()'


class HasValidationError(Filter):
"""
Current buffer has validation error.
Expand Down
11 changes: 8 additions & 3 deletions prompt_toolkit/focus_stack.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""
Push/pop stack of buffer names. The top buffer of the stack is the one that
currently has the focus.

Note that the stack can contain `None` values. This means that none of the
buffers has the focus.
"""
from __future__ import unicode_literals
from six import string_types
Expand Down Expand Up @@ -28,15 +31,17 @@ def pop(self):
if len(self._stack) > 1:
self._stack.pop()
else:
raise Exception('Cannot pop last item from the focus stack.')
raise IndexError('Cannot pop last item from the focus stack.')

def replace(self, buffer_name):
assert isinstance(buffer_name, string_types)
assert buffer_name == None or isinstance(buffer_name, string_types)

self._stack.pop()
self._stack.append(buffer_name)

def push(self, buffer_name):
assert isinstance(buffer_name, string_types)
assert buffer_name == None or isinstance(buffer_name, string_types)

self._stack.append(buffer_name)

@property
Expand Down
15 changes: 12 additions & 3 deletions prompt_toolkit/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from .buffer import Buffer, AcceptAction
from .completion import CompleteEvent
from .completion import get_common_complete_suffix
from .enums import DEFAULT_BUFFER, SEARCH_BUFFER, SYSTEM_BUFFER
from .enums import DEFAULT_BUFFER, SEARCH_BUFFER, SYSTEM_BUFFER, DUMMY_BUFFER
from .eventloop.base import EventLoop
from .eventloop.callbacks import EventLoopCallbacks
from .filters import Condition
Expand Down Expand Up @@ -80,6 +80,7 @@ def __init__(self, application, eventloop=None, input=None, output=None):
DEFAULT_BUFFER: (application.buffer or Buffer(accept_action=AcceptAction.RETURN_DOCUMENT)),
SEARCH_BUFFER: Buffer(history=History(), accept_action=AcceptAction.IGNORE),
SYSTEM_BUFFER: Buffer(history=History(), accept_action=AcceptAction.IGNORE),
DUMMY_BUFFER: Buffer(read_only=True),
}
self.buffers.update(application.buffers)

Expand Down Expand Up @@ -157,16 +158,24 @@ def start_completion(self, buffer_name=None, select_first=False,
@property
def current_buffer_name(self):
"""
The name of the current :class:`Buffer`.
The name of the current :class:`Buffer`. (Or `None`.)
"""
return self.focus_stack.current

@property
def current_buffer(self):
"""
The current focussed :class:`Buffer`.

(This returns a dummy `Buffer` when none of the actual buffers has the
focus. In this case, it's really not practical to check for `None`
values or catch exceptions every time.)
"""
return self.buffers[self.focus_stack.current]
name = self.focus_stack.current
if name is not None:
return self.buffers[self.focus_stack.current]
else:
return self.buffers[DUMMY_BUFFER]

@property
def terminal_title(self):
Expand Down
39 changes: 25 additions & 14 deletions prompt_toolkit/key_binding/bindings/vi.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from prompt_toolkit.buffer import ClipboardData, indent, unindent
from prompt_toolkit.document import Document
from prompt_toolkit.enums import IncrementalSearchDirection, SEARCH_BUFFER, SYSTEM_BUFFER
from prompt_toolkit.filters import Filter, Condition, HasArg, Always, to_cli_filter
from prompt_toolkit.filters import Filter, Condition, HasArg, Always, to_cli_filter, IsReadOnly
from prompt_toolkit.key_binding.vi_state import ViState, CharacterFind, InputMode
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout.utils import find_window_for_buffer_name
Expand Down Expand Up @@ -62,6 +62,15 @@ def load_vi_bindings(registry, vi_state, enable_visual_key=Always(), filter=None
:param enable_visual_key: Filter to enable lowercase 'v' bindings. A reason to disable these
are to support open-in-editor functionality. These key bindings conflict.
"""
# Note: Some key bindings have the "~IsReadOnly()" filter added. This
# prevents the handler to be executed when the focus is on a
# read-only buffer.
# This is however only required for those that change the ViState to
# INSERT mode. The `Buffer` class itself throws the
# `EditReadOnlyBuffer` exception for any text operations which is
# handled correctly. There is no need to add "~IsReadOnly" to all key
# bindings that do text manipulation.

assert isinstance(vi_state, ViState)
enable_visual_key = to_cli_filter(enable_visual_key)

Expand Down Expand Up @@ -218,17 +227,19 @@ def _(event):

# List of navigation commands: http://hea-www.harvard.edu/~fine/Tech/vi.html

@handle('a', filter=navigation_mode)
@handle('a', filter=navigation_mode & ~IsReadOnly())
# ~IsReadOnly, because we want to stay in navigation mode for
# read-only buffers.
def _(event):
event.current_buffer.cursor_position += event.current_buffer.document.get_cursor_right_position()
vi_state.input_mode = InputMode.INSERT

@handle('A', filter=navigation_mode)
@handle('A', filter=navigation_mode & ~IsReadOnly())
def _(event):
event.current_buffer.cursor_position += event.current_buffer.document.get_end_of_line_position()
vi_state.input_mode = InputMode.INSERT

@handle('C', filter=navigation_mode)
@handle('C', filter=navigation_mode & ~IsReadOnly())
def _(event):
"""
# Change to end of line.
Expand All @@ -240,8 +251,8 @@ def _(event):
event.cli.clipboard.set_text(deleted)
vi_state.input_mode = InputMode.INSERT

@handle('c', 'c', filter=navigation_mode)
@handle('S', filter=navigation_mode)
@handle('c', 'c', filter=navigation_mode & ~IsReadOnly())
@handle('S', filter=navigation_mode & ~IsReadOnly())
def _(event): # TODO: implement 'arg'
"""
Change current line
Expand Down Expand Up @@ -290,11 +301,11 @@ def _(event):
# Set clipboard data
event.cli.clipboard.set_data(ClipboardData(deleted, SelectionType.LINES))

@handle('i', filter=navigation_mode)
@handle('i', filter=navigation_mode & ~IsReadOnly())
def _(event):
vi_state.input_mode = InputMode.INSERT

@handle('I', filter=navigation_mode)
@handle('I', filter=navigation_mode & ~IsReadOnly())
def _(event):
vi_state.input_mode = InputMode.INSERT
event.current_buffer.cursor_position += event.current_buffer.document.get_start_of_line_position(after_whitespace=True)
Expand Down Expand Up @@ -362,7 +373,7 @@ def _(event):
"""
vi_state.input_mode = InputMode.REPLACE

@handle('s', filter=navigation_mode)
@handle('s', filter=navigation_mode & ~IsReadOnly())
def _(event):
"""
Substitute with new text
Expand Down Expand Up @@ -412,7 +423,7 @@ def _(event):
clipboard_data = event.current_buffer.cut_selection()
event.cli.clipboard.set_data(clipboard_data)

@handle('c', filter=selection_mode)
@handle('c', filter=selection_mode & ~IsReadOnly())
def _(event):
"""
Change selection (cut and go to insert mode).
Expand Down Expand Up @@ -508,7 +519,7 @@ def _(event):

unindent(buffer, from_ - 1, to, count=event.arg)

@handle('O', filter=navigation_mode)
@handle('O', filter=navigation_mode & ~IsReadOnly())
def _(event):
"""
Open line above and enter insertion mode
Expand All @@ -517,7 +528,7 @@ def _(event):
copy_margin=not event.cli.in_paste_mode)
vi_state.input_mode = InputMode.INSERT

@handle('o', filter=navigation_mode)
@handle('o', filter=navigation_mode & ~IsReadOnly())
def _(event):
"""
Open line below and enter insertion mode
Expand Down Expand Up @@ -646,8 +657,8 @@ def visual_handler(event):

def create(delete_only):
""" Create delete and change handlers. """
@handle('cd'[delete_only], *keys, filter=navigation_mode)
@handle('cd'[delete_only], *keys, filter=navigation_mode)
@handle('cd'[delete_only], *keys, filter=navigation_mode & ~IsReadOnly())
@handle('cd'[delete_only], *keys, filter=navigation_mode & ~IsReadOnly())
def _(event):
region = func(event)
deleted = ''
Expand Down
20 changes: 16 additions & 4 deletions prompt_toolkit/key_binding/input_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import unicode_literals
from ..keys import Keys
from ..utils import Callback
from prompt_toolkit.buffer import EditReadOnlyBuffer

import weakref

Expand Down Expand Up @@ -132,6 +133,11 @@ def _process(self):
is_prefix_of_longer_match = self._is_prefix_of_longer_match(buffer)
matches = self._get_matches(buffer)

# When longer matches were found, but the current match is
# 'eager', ignore all the longer matches.
if matches and matches[-1].eager(self._cli_ref()):
is_prefix_of_longer_match = False

# Exact matches found, call handler.
if not is_prefix_of_longer_match and matches:
self._call_handler(matches[-1], key_sequence=buffer)
Expand Down Expand Up @@ -171,10 +177,16 @@ def _call_handler(self, handler, key_sequence=None):
arg = self.arg
self.arg = None

event = Event(weakref.ref(self), arg=arg, key_sequence=key_sequence,
previous_key_sequence=self._previous_key_sequence)
handler.call(event)
self._registry.on_handler_called.fire(event)
try:
event = Event(weakref.ref(self), arg=arg, key_sequence=key_sequence,
previous_key_sequence=self._previous_key_sequence)
handler.call(event)
self._registry.on_handler_called.fire(event)

except EditReadOnlyBuffer:
# When a key binding does an attempt to change a buffer which is read-only,
# we can just silently ignore that.
pass

self._previous_key_sequence = key_sequence

Expand Down
14 changes: 12 additions & 2 deletions prompt_toolkit/key_binding/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ class _Binding(object):
"""
(Immutable binding class.)
"""
def __init__(self, keys, handler, filter=None):
def __init__(self, keys, handler, filter=None, eager=None):
assert isinstance(keys, tuple)
assert callable(handler)
assert isinstance(filter, CLIFilter)
assert isinstance(eager, CLIFilter)

self.keys = keys
self._handler = handler
self.filter = filter
self.eager = eager

def call(self, event):
return self._handler(event)
Expand Down Expand Up @@ -57,8 +59,16 @@ def __init__(self):
def add_binding(self, *keys, **kwargs):
"""
Decorator for annotating key bindings.

:param filter: `CLIFilter` to determine when this key binding is active.
:param eager: `CLIFilter` or `bool`. When True, ignore potential longer
matches when this key binding is hit. E.g. when there is an
active eager key binding for Ctrl-X, execute the handler
immediately and ignore the key binding for Ctrl-X Ctrl-E
of which it is a prefix.
"""
filter = to_cli_filter(kwargs.pop('filter', True))
eager = to_cli_filter(kwargs.pop('eager', False))

assert not kwargs
assert keys
Expand All @@ -69,7 +79,7 @@ def decorator(func):
# press otherwise. (This happens for instance when a KeyBindingManager
# is used, but some set of bindings are always disabled.)
if not isinstance(filter, Never):
binding = _Binding(keys, func, filter=filter)
binding = _Binding(keys, func, filter=filter, eager=eager)

self.key_bindings.append(binding)
self._keys_to_bindings[keys].append(binding)
Expand Down
Loading