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