blob: 6fb53f98b901fab330e51ef2dc41c599847db53d [file] [log] [blame]
# Copyright 2021 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""Text formatting functions."""
import copy
import re
from typing import Iterable, List, Tuple
from prompt_toolkit.formatted_text import StyleAndTextTuples
from prompt_toolkit.formatted_text.base import OneStyleAndTextTuple
from prompt_toolkit.utils import get_cwidth
_ANSI_SEQUENCE_REGEX = re.compile(r'\x1b[^m]*m')
def strip_ansi(text: str):
"""Strip out ANSI escape sequences."""
return _ANSI_SEQUENCE_REGEX.sub('', text)
def split_lines(
input_fragments: StyleAndTextTuples) -> List[StyleAndTextTuples]:
"""Break a flattened list of StyleAndTextTuples into a list of lines.
Ending line breaks are not preserved."""
lines: List[StyleAndTextTuples] = []
this_line: StyleAndTextTuples = []
for item in input_fragments:
if item[1].endswith('\n'):
# If there are no elements in this line except for a linebreak add
# an empty StyleAndTextTuple so this line isn't an empty list.
if len(this_line) == 0 and item[1] == '\n':
this_line.append((item[0], item[1][:-1]))
lines.append(this_line)
this_line = []
else:
this_line.append(item)
return lines
def insert_linebreaks(
input_fragments: StyleAndTextTuples,
max_line_width: int,
truncate_long_lines: bool = True) -> Tuple[StyleAndTextTuples, int]:
"""Add line breaks at max_line_width if truncate_long_lines is True.
Returns input_fragments with each character as it's own formatted text
tuple."""
fragments: StyleAndTextTuples = []
total_width = 0
line_width = 0
line_height = 0
new_break_inserted = False
for item in input_fragments:
# Check for non-printable fragment; doesn't affect the width.
if '[ZeroWidthEscape]' in item[0]:
fragments.append(item)
continue
new_item_style = item[0]
# For each character in the fragment
for character in item[1]:
# Get the width respecting double width characters
width = get_cwidth(character)
# Increment counters
total_width += width
line_width += width
# Save this character as it's own fragment
if line_width <= max_line_width:
if not new_break_inserted or character != '\n':
fragments.append((new_item_style, character))
# Was a line break just inserted?
if character == '\n':
# Increase height
line_height += 1
new_break_inserted = False
# Reset width to zero even if we are beyond the max line width.
if character == '\n':
line_width = 0
# Are we at the limit for this line?
elif line_width == max_line_width:
# Insert a new linebreak fragment
fragments.append((new_item_style, '\n'))
# Increase height
line_height += 1
# Set a flag for skipping the next character if it is also a
# line break.
new_break_inserted = True
if not truncate_long_lines:
# Reset line width to zero
line_width = 0
# Check if the string ends in a final line break
last_fragment_style = fragments[-1][0]
last_fragment_text = fragments[-1][1]
if not last_fragment_text.endswith('\n'):
# Add a line break if none exists
fragments.append((last_fragment_style, '\n'))
line_height += 1
return fragments, line_height
def join_adjacent_style_tuples(
fragments: StyleAndTextTuples) -> StyleAndTextTuples:
"""Join adjacent FormattedTextTuples if they have the same style."""
new_fragments: StyleAndTextTuples = []
for i, fragment in enumerate(fragments):
# Add the first fragment
if i == 0:
new_fragments.append(fragment)
continue
# Get this style
style = fragment[0]
# If the previous style matches
if style == new_fragments[-1][0]:
# Get the previous text
new_text = new_fragments[-1][1]
# Append this text
new_text += fragment[1]
# Replace the last fragment
new_fragments[-1] = (style, new_text)
else:
# Styles don't match, just append.
new_fragments.append(fragment)
return new_fragments
def fill_character_width(input_fragments: StyleAndTextTuples,
fragment_width: int,
window_width: int,
line_wrapping: bool = False,
remaining_width: int = 0,
horizontal_scroll_amount: int = 0,
add_cursor: bool = False) -> StyleAndTextTuples:
"""Fill line to the width of the window using spaces."""
# Calculate the number of spaces to add at the end.
empty_characters = window_width - fragment_width
# If wrapping is on, use remaining_width
if line_wrapping and (fragment_width > window_width):
empty_characters = remaining_width
# Add additional spaces for horizontal scrolling.
empty_characters += horizontal_scroll_amount
if empty_characters <= 0:
# No additional spaces required
return input_fragments
line_fragments = copy.copy(input_fragments)
single_space = ('', ' ')
line_ends_in_a_break = False
# Replace the trailing \n with a space
if line_fragments[-1][1] == '\n':
line_fragments[-1] = single_space
empty_characters -= 1
line_ends_in_a_break = True
# Append remaining spaces
for _i in range(empty_characters):
line_fragments.append(single_space)
if line_ends_in_a_break:
# Restore the \n
line_fragments.append(('', '\n'))
if add_cursor:
# Add a cursor to this line by adding SetCursorPosition fragment.
line_fragments_remainder = line_fragments
line_fragments = [('[SetCursorPosition]', '')]
# Use extend to keep types happy.
line_fragments.extend(line_fragments_remainder)
return line_fragments
def flatten_formatted_text_tuples(
lines: Iterable[StyleAndTextTuples]) -> StyleAndTextTuples:
"""Flatten a list of lines of FormattedTextTuples
This function will also remove trailing newlines to avoid displaying extra
empty lines in prompt_toolkit containers.
"""
fragments: StyleAndTextTuples = []
# Return empty list if lines is empty.
if not lines:
return fragments
for line_fragments in lines:
# Append all FormattedText tuples for this line.
for fragment in line_fragments:
fragments.append(fragment)
# Strip off any trailing line breaks
last_fragment: OneStyleAndTextTuple = fragments[-1]
style = last_fragment[0]
text = last_fragment[1].rstrip('\n')
fragments[-1] = (style, text)
return fragments
def remove_formatting(formatted_text: StyleAndTextTuples) -> str:
"""Throw away style info from prompt_toolkit formatted text tuples."""
return ''.join([formatted_tuple[1] for formatted_tuple in formatted_text])
def get_line_height(text_width, screen_width, prefix_width):
"""Calculates line height for a string with line wrapping enabled."""
if text_width == 0:
return 0
# If text will fit on the screen without wrapping.
if text_width <= screen_width:
return 1, screen_width - text_width
# Assume zero width prefix if it's >= width of the screen.
if prefix_width >= screen_width:
prefix_width = 0
# Start with height of 1 row.
total_height = 1
# One screen_width of characters (with no prefix) is displayed first.
remaining_width = text_width - screen_width
# While we have caracters remaining to be displayed
while remaining_width > 0:
# Add the new indentation prefix
remaining_width += prefix_width
# Display this line
remaining_width -= screen_width
# Add a line break
total_height += 1
# Remaining characters is what's left below zero.
return (total_height, abs(remaining_width))