diff --git a/examples/full-screen/simple-demos/word-wrapping.py b/examples/full-screen/simple-demos/word-wrapping.py
new file mode 100755
index 000000000..e6a160eb5
--- /dev/null
+++ b/examples/full-screen/simple-demos/word-wrapping.py
@@ -0,0 +1,154 @@
+#!/usr/bin/env python
+"""
+An example of a BufferControl in a full screen layout that offers auto
+completion.
+
+Important is to make sure that there is a `CompletionsMenu` in the layout,
+otherwise the completions won't be visible.
+"""
+
+from prompt_toolkit.application import Application
+from prompt_toolkit.buffer import Buffer
+from prompt_toolkit.filters import Condition
+from prompt_toolkit.formatted_text import HTML, to_formatted_text
+from prompt_toolkit.key_binding import KeyBindings
+from prompt_toolkit.layout.containers import Float, FloatContainer, HSplit, Window
+from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
+from prompt_toolkit.layout.layout import Layout
+from prompt_toolkit.layout.menus import CompletionsMenu
+
+LIPSUM = " ".join(
+ """\
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas
+quis interdum enim. Nam viverra, mauris et blandit malesuada, ante est bibendum
+mauris, ac dignissim dui tellus quis ligula. Aenean condimentum leo at
+dignissim placerat. In vel dictum ex, vulputate accumsan mi. Donec ut quam
+placerat massa tempor elementum. Sed tristique mauris ac suscipit euismod. Ut
+tempus vehicula augue non venenatis. Mauris aliquam velit turpis, nec congue
+risus aliquam sit amet. Pellentesque blandit scelerisque felis, faucibus
+consequat ante. Curabitur tempor tortor a imperdiet tincidunt. Nam sed justo
+sit amet odio bibendum congue. Quisque varius ligula nec ligula gravida, sed
+convallis augue faucibus. Nunc ornare pharetra bibendum. Praesent blandit ex
+quis sodales maximus.""".split("\n")
+)
+
+
+def get_line_prefix(lineno, wrap_count):
+ if wrap_count == 0:
+ return HTML('[%s] ') % lineno
+
+ text = str(lineno) + "-" + "*" * (lineno // 2) + ": "
+ return HTML('[%s.%s] ') % (
+ lineno,
+ wrap_count,
+ text,
+ )
+
+
+# Global wrap lines flag.
+wrap_lines = True
+
+
+# The layout
+buff = Buffer(complete_while_typing=True)
+buff.text = LIPSUM
+
+
+body = FloatContainer(
+ content=HSplit(
+ [
+ Window(
+ FormattedTextControl(
+ 'Press "q" to quit. Press "w" to enable/disable wrapping.'
+ ),
+ height=1,
+ style="reverse",
+ ),
+ # Break words
+ Window(
+ BufferControl(buffer=buff),
+ wrap_lines=Condition(lambda: wrap_lines),
+ word_wrap=False,
+ ),
+ # Default word wrapping
+ Window(
+ BufferControl(buffer=buff),
+ wrap_lines=Condition(lambda: wrap_lines),
+ word_wrap=True,
+ ),
+ # Add a marker to signify continuation
+ Window(
+ BufferControl(buffer=buff),
+ wrap_lines=True,
+ wrap_finder=Window._whitespace_wrap_finder(
+ continuation=to_formatted_text(" ⮠")
+ ),
+ get_line_prefix=lambda lineno, wrap_count: to_formatted_text(" ⭢ ")
+ if wrap_count
+ else [],
+ ),
+ # Truncating (only wrap the first time around)
+ Window(
+ BufferControl(buffer=buff),
+ wrap_lines=True,
+ wrap_finder=lambda line,
+ lineno,
+ wrap_count,
+ start,
+ end,
+ fallback=Window._whitespace_wrap_finder(): (end - 3, -1, "...")
+ if wrap_count > 0
+ else fallback(line, lineno, wrap_count, start, end),
+ ),
+ # Split only after vowels
+ Window(
+ BufferControl(buffer=buff),
+ wrap_lines=True,
+ wrap_finder=Window._whitespace_wrap_finder(
+ sep="[aeiouyAEIOUY]",
+ split="after",
+ continuation=to_formatted_text("-"),
+ ),
+ ),
+ ]
+ ),
+ floats=[
+ Float(
+ xcursor=True,
+ ycursor=True,
+ content=CompletionsMenu(max_height=16, scroll_offset=1),
+ )
+ ],
+)
+
+
+# Key bindings
+kb = KeyBindings()
+
+
+@kb.add("q")
+@kb.add("c-c")
+def _(event):
+ "Quit application."
+ event.app.exit()
+
+
+@kb.add("w")
+def _(event):
+ "Disable/enable wrapping."
+ global wrap_lines
+ wrap_lines = not wrap_lines
+
+
+# The `Application`
+application = Application(
+ layout=Layout(body), key_bindings=kb, full_screen=True, mouse_support=True
+)
+
+
+def run():
+ application.run()
+
+
+if __name__ == "__main__":
+ run()
diff --git a/examples/prompts/auto-completion/fuzzy-custom-completer.py b/examples/prompts/auto-completion/fuzzy-custom-completer.py
index ca763c7d1..2e5b0324b 100755
--- a/examples/prompts/auto-completion/fuzzy-custom-completer.py
+++ b/examples/prompts/auto-completion/fuzzy-custom-completer.py
@@ -36,21 +36,28 @@ def get_completions(self, document, complete_event):
def main():
# Simple completion menu.
print("(The completion menu displays colors.)")
- prompt("Type a color: ", completer=FuzzyCompleter(ColorCompleter()))
+ r = prompt(
+ "Type a color: ",
+ completer=FuzzyCompleter(ColorCompleter()),
+ complete_style=CompleteStyle.MULTI_COLUMN,
+ )
+ print(r)
# Multi-column menu.
- prompt(
+ r = prompt(
"Type a color: ",
completer=FuzzyCompleter(ColorCompleter()),
complete_style=CompleteStyle.MULTI_COLUMN,
)
+ print(r)
# Readline-like
- prompt(
+ r = prompt(
"Type a color: ",
completer=FuzzyCompleter(ColorCompleter()),
complete_style=CompleteStyle.READLINE_LIKE,
)
+ print(r)
if __name__ == "__main__":
diff --git a/src/prompt_toolkit/layout/containers.py b/src/prompt_toolkit/layout/containers.py
index 99b453477..e618514ad 100644
--- a/src/prompt_toolkit/layout/containers.py
+++ b/src/prompt_toolkit/layout/containers.py
@@ -5,6 +5,8 @@
from __future__ import annotations
+import re
+import sys
from abc import ABCMeta, abstractmethod
from enum import Enum
from functools import partial
@@ -23,8 +25,10 @@
AnyFormattedText,
StyleAndTextTuples,
to_formatted_text,
+ to_plain_text,
)
from prompt_toolkit.formatted_text.utils import (
+ fragment_list_len,
fragment_list_to_text,
fragment_list_width,
)
@@ -38,6 +42,7 @@
GetLinePrefixCallable,
UIContent,
UIControl,
+ WrapFinderCallable,
)
from .dimension import (
AnyDimension,
@@ -1310,7 +1315,10 @@ def get_height_for_line(self, lineno: int) -> int:
"""
if self.wrap_lines:
return self.ui_content.get_height_for_line(
- lineno, self.window_width, self.window.get_line_prefix
+ lineno,
+ self.window_width,
+ self.window.get_line_prefix,
+ self.window.wrap_finder,
)
else:
return 1
@@ -1442,6 +1450,10 @@ class Window(Container):
wrap_count and returns formatted text. This can be used for
implementation of line continuations, things like Vim "breakindent" and
so on.
+ :param wrap_finder: None or a callable that returns how to wrap a line.
+ It takes a line number, a start and an end position (ints) and returns
+ the the wrap position, a number of characters to be skipped (if any),
+ and formatted text for the continuation marker.
"""
def __init__(
@@ -1459,6 +1471,7 @@ def __init__(
scroll_offsets: ScrollOffsets | None = None,
allow_scroll_beyond_bottom: FilterOrBool = False,
wrap_lines: FilterOrBool = False,
+ word_wrap: FilterOrBool = False,
get_vertical_scroll: Callable[[Window], int] | None = None,
get_horizontal_scroll: Callable[[Window], int] | None = None,
always_hide_cursor: FilterOrBool = False,
@@ -1471,10 +1484,12 @@ def __init__(
style: str | Callable[[], str] = "",
char: None | str | Callable[[], str] = None,
get_line_prefix: GetLinePrefixCallable | None = None,
+ wrap_finder: WrapFinderCallable | None = None,
) -> None:
self.allow_scroll_beyond_bottom = to_filter(allow_scroll_beyond_bottom)
self.always_hide_cursor = to_filter(always_hide_cursor)
self.wrap_lines = to_filter(wrap_lines)
+ self.word_wrap = to_filter(word_wrap)
self.cursorline = to_filter(cursorline)
self.cursorcolumn = to_filter(cursorcolumn)
@@ -1493,6 +1508,7 @@ def __init__(
self.style = style
self.char = char
self.get_line_prefix = get_line_prefix
+ self.wrap_finder = wrap_finder
self.width = width
self.height = height
@@ -1601,6 +1617,7 @@ def preferred_content_height() -> int | None:
max_available_height,
wrap_lines,
self.get_line_prefix,
+ self.wrap_finder,
)
return self._merge_dimensions(
@@ -1766,6 +1783,9 @@ def _write_to_screen_at_index(
self._scroll(
ui_content, write_position.width - total_margin_width, write_position.height
)
+ wrap_finder = self.wrap_finder or (
+ self._whitespace_wrap_finder() if self.word_wrap() else None
+ )
# Erase background and fill with `char`.
self._fill_bg(screen, write_position, erase_bg)
@@ -1789,6 +1809,7 @@ def _write_to_screen_at_index(
has_focus=get_app().layout.current_control == self.content,
align=align,
get_line_prefix=self.get_line_prefix,
+ wrap_finder=wrap_finder,
)
# Remember render info. (Set before generating the margins. They need this.)
@@ -1920,6 +1941,50 @@ def render_margin(m: Margin, width: int) -> UIContent:
# position.
screen.visible_windows_to_write_positions[self] = write_position
+ @classmethod
+ def _whitespace_wrap_finder(
+ cls,
+ sep: str | re.Pattern[str] = r"[ \t]", # Don’t include \xA0 by default (in \s)
+ split: str = "remove",
+ continuation: StyleAndTextTuples = [],
+ ) -> WrapFinderCallable:
+ """Returns a function that defines where to break"""
+ sep_re = sep if isinstance(sep, re.Pattern) else re.compile(sep)
+ if sep_re.groups:
+ raise ValueError(
+ f"Pattern {sep_re.pattern!r} has capture group – use non-capturing groups instead"
+ )
+ elif split == "after":
+ sep_re = re.compile(f"(?={sep_re.pattern})()")
+ elif split == "before":
+ sep_re = re.compile(f"(?<={sep_re.pattern})()")
+ elif split == "remove":
+ sep_re = re.compile(f"({sep_re.pattern})")
+ else:
+ raise ValueError(f"Unrecognized value of split parameter: {split!r}")
+
+ cont_width = fragment_list_width(continuation)
+
+ def wrap_finder(
+ line: AnyFormattedText, lineno: int, wrap_count: int, start: int, end: int
+ ) -> tuple[int, int, AnyFormattedText]:
+ line = explode_text_fragments(to_formatted_text(line))
+ cont_reserved = 0
+ while cont_reserved < cont_width:
+ style, char, *_ = line[end - 1]
+ cont_reserved += get_cwidth(char)
+ end -= 1
+
+ segment = to_plain_text(line[start:end])
+ try:
+ after, sep, before = sep_re.split(segment[::-1], maxsplit=1)
+ except ValueError:
+ return (end, 0, continuation)
+ else:
+ return (start + len(before), len(sep), continuation)
+
+ return wrap_finder
+
def _copy_body(
self,
ui_content: UIContent,
@@ -1936,6 +2001,7 @@ def _copy_body(
has_focus: bool = False,
align: WindowAlign = WindowAlign.LEFT,
get_line_prefix: Callable[[int, int], AnyFormattedText] | None = None,
+ wrap_finder: WrapFinderCallable | None = None,
) -> tuple[dict[int, tuple[int, int]], dict[tuple[int, int], tuple[int, int]]]:
"""
Copy the UIContent into the output screen.
@@ -1957,6 +2023,58 @@ def _copy_body(
# Maps (row, col) from the input to (y, x) screen coordinates.
rowcol_to_yx: dict[tuple[int, int], tuple[int, int]] = {}
+ def find_next_wrap(
+ line: StyleAndTextTuples,
+ remaining_width: int,
+ is_input: bool,
+ lineno: int,
+ wrap_count: int,
+ fragment: int = 0,
+ char_pos: int = 0,
+ ) -> tuple[int, int, AnyFormattedText]:
+ if not wrap_lines:
+ return sys.maxsize, 0, []
+
+ try:
+ style0, text0, *more = line[fragment]
+ except IndexError:
+ return sys.maxsize, 0, []
+
+ frag_char_pos = char_pos - fragment_list_len(line[:fragment])
+ line_part = [(style0, text0[frag_char_pos:]), *line[fragment + 1 :]]
+ line_width = [fragment_list_width([frag]) for frag in line_part]
+ line_width = [fragment_list_width([frag]) for frag in line_part]
+
+ if sum(line_width) <= remaining_width:
+ return sys.maxsize, 0, []
+
+ min_wrap_pos = max_wrap_pos = char_pos
+ for next_fragment, fragment_width in zip(line_part, line_width):
+ if remaining_width < fragment_width:
+ break
+ remaining_width -= fragment_width
+ max_wrap_pos += fragment_list_len([next_fragment])
+ else:
+ # Should never happen
+ return sys.maxsize, 0, []
+
+ style, text, *_ = next_fragment
+ for char_width in (get_cwidth(char) for char in text):
+ if remaining_width < char_width:
+ break
+ remaining_width -= char_width
+ max_wrap_pos += 1
+
+ return (
+ wrap_finder(line, lineno, wrap_count, min_wrap_pos, max_wrap_pos)
+ if is_input and wrap_finder
+ else None
+ ) or (
+ max_wrap_pos,
+ 0,
+ [],
+ )
+
def copy_line(
line: StyleAndTextTuples,
lineno: int,
@@ -2003,27 +2121,53 @@ def copy_line(
if line_width < width:
x += width - line_width
+ new_buffer_row = new_buffer[y + ypos]
+ wrap_start, wrap_replaced, continuation = find_next_wrap(
+ line,
+ width - x,
+ is_input,
+ lineno,
+ 0,
+ )
+ continuation = to_formatted_text(continuation)
+
col = 0
wrap_count = 0
- for style, text, *_ in line:
- new_buffer_row = new_buffer[y + ypos]
-
+ wrap_skip = 0
+ text_end = 0
+ for fragment_count, (style, text, *_) in enumerate(line):
# Remember raw VT escape sequences. (E.g. FinalTerm's
# escape sequences.)
if "[ZeroWidthEscape]" in style:
new_screen.zero_width_escapes[y + ypos][x + xpos] += text
continue
- for c in text:
+ text_start, text_end = text_end, text_end + len(text)
+
+ for char_count, c in enumerate(text, text_start):
char = _CHAR_CACHE[c, style]
char_width = char.width
# Wrap when the line width is exceeded.
- if wrap_lines and x + char_width > width:
+ if wrap_lines and char_count == wrap_start:
+ skipped_width = sum(
+ get_cwidth(char)
+ for char in text[wrap_start - text_start :][:wrap_replaced]
+ )
+ col += wrap_replaced
visible_line_to_row_col[y + 1] = (
lineno,
- visible_line_to_row_col[y][1] + x,
+ visible_line_to_row_col[y][1] + x + skipped_width,
)
+
+ # Append continuation (e.g. hyphen)
+ if continuation:
+ x, y = copy_line(continuation, lineno, x, y, is_input=False)
+
+ if wrap_replaced < 0:
+ return x, y
+
+ wrap_skip = wrap_replaced
y += 1
wrap_count += 1
x = 0
@@ -2036,10 +2180,25 @@ def copy_line(
x, y = copy_line(prompt, lineno, x, y, is_input=False)
new_buffer_row = new_buffer[y + ypos]
+ wrap_start, wrap_replaced, continuation = find_next_wrap(
+ line,
+ width - x,
+ is_input,
+ lineno,
+ wrap_count,
+ fragment_count,
+ wrap_start + wrap_replaced,
+ )
+ continuation = to_formatted_text(continuation)
if y >= write_position.height:
return x, y # Break out of all for loops.
+ # Chars skipped by wrapping (e.g. whitespace)
+ if wrap_lines and wrap_skip > 0:
+ wrap_skip -= 1
+ continue
+
# Set character in screen and shift 'x'.
if x >= 0 and y >= 0 and x < width:
new_buffer_row[x + xpos] = char
@@ -2329,7 +2488,9 @@ def _scroll_when_linewrapping(
self.horizontal_scroll = 0
def get_line_height(lineno: int) -> int:
- return ui_content.get_height_for_line(lineno, width, self.get_line_prefix)
+ return ui_content.get_height_for_line(
+ lineno, width, self.get_line_prefix, self.wrap_finder
+ )
# When there is no space, reset `vertical_scroll_2` to zero and abort.
# This can happen if the margin is bigger than the window width.
@@ -2354,6 +2515,7 @@ def get_line_height(lineno: int) -> int:
ui_content.cursor_position.y,
width,
self.get_line_prefix,
+ self.wrap_finder,
slice_stop=ui_content.cursor_position.x,
)
diff --git a/src/prompt_toolkit/layout/controls.py b/src/prompt_toolkit/layout/controls.py
index 222e471c5..d9f5c68c3 100644
--- a/src/prompt_toolkit/layout/controls.py
+++ b/src/prompt_toolkit/layout/controls.py
@@ -6,7 +6,7 @@
import time
from abc import ABCMeta, abstractmethod
-from typing import TYPE_CHECKING, Callable, Hashable, Iterable, NamedTuple
+from typing import TYPE_CHECKING, Callable, Hashable, Iterable, NamedTuple, Tuple
from prompt_toolkit.application.current import get_app
from prompt_toolkit.buffer import Buffer
@@ -58,6 +58,9 @@
]
GetLinePrefixCallable = Callable[[int, int], AnyFormattedText]
+WrapFinderCallable = Callable[
+ [AnyFormattedText, int, int, int, int], Tuple[int, int, AnyFormattedText]
+]
class UIControl(metaclass=ABCMeta):
@@ -78,6 +81,7 @@ def preferred_height(
max_available_height: int,
wrap_lines: bool,
get_line_prefix: GetLinePrefixCallable | None,
+ wrap_finder: WrapFinderCallable | None,
) -> int | None:
return None
@@ -178,6 +182,7 @@ def get_height_for_line(
lineno: int,
width: int,
get_line_prefix: GetLinePrefixCallable | None,
+ wrap_finder: WrapFinderCallable | None,
slice_stop: int | None = None,
) -> int:
"""
@@ -203,34 +208,53 @@ def get_height_for_line(
height = 10**8
else:
# Calculate line width first.
- line = fragment_list_to_text(self.get_line(lineno))[:slice_stop]
- text_width = get_cwidth(line)
+ line = self.get_line(lineno)
+ line_text = fragment_list_to_text(line)[:slice_stop]
+ start = 0
+ text_width = get_cwidth(line_text[start:])
- if get_line_prefix:
+ if get_line_prefix or wrap_finder:
# Add prefix width.
- text_width += fragment_list_width(
- to_formatted_text(get_line_prefix(lineno, 0))
- )
+ if get_line_prefix:
+ prefix_width = fragment_list_width(
+ to_formatted_text(get_line_prefix(lineno, 0))
+ )
+ else:
+ prefix_width = 0
# Slower path: compute path when there's a line prefix.
height = 1
# Keep wrapping as long as the line doesn't fit.
# Keep adding new prefixes for every wrapped line.
- while text_width > width:
+ while prefix_width + text_width > width:
height += 1
- text_width -= width
-
- fragments2 = to_formatted_text(
- get_line_prefix(lineno, height - 1)
- )
- prefix_width = get_cwidth(fragment_list_to_text(fragments2))
+ if wrap_finder:
+ # Decent guess for max breakpoint place?
+ end = start + width - prefix_width
+ start_end_width = get_cwidth(line_text[start:end])
+ while start_end_width >= width - prefix_width:
+ start_end_width -= get_cwidth(line_text[end - 1])
+ end -= 1
+ wrap, skip, cont = wrap_finder(
+ line, lineno, height - 1, start, end
+ )
+ if skip < 0:
+ break # Truncate line
+ start = wrap + skip
+ text_width = get_cwidth(line_text[start:])
+ else:
+ text_width -= width
- if prefix_width >= width: # Prefix doesn't fit.
- height = 10**8
- break
+ if get_line_prefix:
+ fragments2 = to_formatted_text(
+ get_line_prefix(lineno, height - 1)
+ )
+ prefix_width = get_cwidth(fragment_list_to_text(fragments2))
- text_width += prefix_width
+ if prefix_width >= width: # Prefix doesn't fit.
+ height = 10**8
+ break
else:
# Fast path: compute height when there's no line prefix.
try:
@@ -354,6 +378,7 @@ def preferred_height(
max_available_height: int,
wrap_lines: bool,
get_line_prefix: GetLinePrefixCallable | None,
+ wrap_finder: WrapFinderCallable | None,
) -> int | None:
"""
Return the preferred height for this control.
@@ -362,7 +387,9 @@ def preferred_height(
if wrap_lines:
height = 0
for i in range(content.line_count):
- height += content.get_height_for_line(i, width, get_line_prefix)
+ height += content.get_height_for_line(
+ i, width, get_line_prefix, wrap_finder
+ )
if height >= max_available_height:
return max_available_height
return height
@@ -614,6 +641,7 @@ def preferred_height(
max_available_height: int,
wrap_lines: bool,
get_line_prefix: GetLinePrefixCallable | None,
+ wrap_finder: WrapFinderCallable | None,
) -> int | None:
# Calculate the content height, if it was drawn on a screen with the
# given width.
@@ -631,7 +659,9 @@ def preferred_height(
return max_available_height
for i in range(content.line_count):
- height += content.get_height_for_line(i, width, get_line_prefix)
+ height += content.get_height_for_line(
+ i, width, get_line_prefix, wrap_finder
+ )
if height >= max_available_height:
return max_available_height
diff --git a/src/prompt_toolkit/layout/menus.py b/src/prompt_toolkit/layout/menus.py
index 612e8ab6a..4e92bdb26 100644
--- a/src/prompt_toolkit/layout/menus.py
+++ b/src/prompt_toolkit/layout/menus.py
@@ -27,7 +27,7 @@
from prompt_toolkit.utils import get_cwidth
from .containers import ConditionalContainer, HSplit, ScrollOffsets, Window
-from .controls import GetLinePrefixCallable, UIContent, UIControl
+from .controls import GetLinePrefixCallable, UIContent, UIControl, WrapFinderCallable
from .dimension import Dimension
from .margins import ScrollbarMargin
@@ -80,6 +80,7 @@ def preferred_height(
max_available_height: int,
wrap_lines: bool,
get_line_prefix: GetLinePrefixCallable | None,
+ wrap_finder: WrapFinderCallable | None,
) -> int | None:
complete_state = get_app().current_buffer.complete_state
if complete_state:
@@ -378,6 +379,7 @@ def preferred_height(
max_available_height: int,
wrap_lines: bool,
get_line_prefix: GetLinePrefixCallable | None,
+ wrap_finder: WrapFinderCallable | None,
) -> int | None:
"""
Preferred height: as much as needed in order to display all the completions.
@@ -718,6 +720,7 @@ def preferred_height(
max_available_height: int,
wrap_lines: bool,
get_line_prefix: GetLinePrefixCallable | None,
+ wrap_finder: WrapFinderCallable | None,
) -> int | None:
return 1