Source code for framed_text.framed_text

from typing import Literal

from termcolor import colored as col

from framed_text.color import Color
from framed_text.labeled_data import LabeledData, AnyLabeledData
from framed_text.shorten_text import ShortenText
from framed_text.status import AnyStatus
from framed_text.utils import (
    _remove_ansi,
    _shorten_framed_title,
    _validate_attrs,
    _center_text,
    _calculate_dividers,
    _format_col_text,
    _ansi_pipe_clean,
    _get_terminal_width,
)


# ------------------------------------------------------------------------------------------
# Helper functions for FramedText and other classes which use frames
# ------------------------------------------------------------------------------------------
def _frame_draw_top(title: str | None = None,
                    title_len: int = 0,
                    frame_size: Literal["full", "dynamic"] = "full",
                    line_len: int = -1,
                    frame_color: str | tuple[int, int, int] | Color | None = None) -> str:
    """
    Draw the top half of the frame
    :param title: Optional title
    :param title_len: Length of the title
    :param frame_size: Size of the frame. "full" for terminal size, "dynamic" for text size
    :param line_len: Length of the longest line of the text. Used with ``frame_size="dynamic"``
    :param frame_color: Optional frame color
    :return: Top half of the frame
    """
    _line: str = ''

    if isinstance(frame_color, Color):
        _frame_color = frame_color.tc_color
    else:
        _frame_color = frame_color

    def _get_line(width: int, offset: int) -> str:
        """
        Formats the top half of the frame

        :param width: Length of line
        :param offset: Offset to width
        :return: Top half of the frame
        """
        if _frame_color and title:
            # Color and Title
            start_len, end_len = _calculate_dividers(width=width, offset=offset, title_len=title_len)
            return f"{col(f"┌{'─' * start_len}| ", _frame_color)}{title}{col(f" |{'─' * end_len}┐", _frame_color)}"

        elif _frame_color and not title:
            # Color
            start_len, end_len = _calculate_dividers(width=width, offset=2)
            _line_len: int = start_len + end_len
            return col(f"┌{'─' * _line_len}┐", _frame_color)

        elif title:
            # Title
            start_len, end_len = _calculate_dividers(width=width, offset=offset, title_len=title_len)
            return f"┌{'─' * start_len}| {title} |{'─' * end_len}┐"

        else:
            # None
            start_len, end_len = _calculate_dividers(width=width, offset=2)
            _line_len: int = start_len + end_len
            return f"┌{'─' * _line_len}┐"

    match frame_size:
        case "full":
            term_width: int = _get_terminal_width()
            _line = _get_line(width=term_width, offset=6)

        case "dynamic":
            _line = _get_line(width=line_len, offset=2)

    return _ansi_pipe_clean(text=_line)


def _frame_draw_bottom(frame_size: Literal["full", "dynamic"] = "full",
                       line_len: int = -1,
                       frame_color: str | tuple[int, int, int] | Color | None = None) -> str:
    """
    Draw the bottom half of the frame
    :param frame_color: Optional frame color
    :param frame_size: Size of the frame. "full" for terminal size, "dynamic" for text size
    :param line_len: Length of the longest line of the text. Used with ``frame_size="dynamic"``
    :return: Bottom half of the frame
    """
    _line: str = ''
    _term_width: int = _get_terminal_width()

    if isinstance(frame_color, Color):
        _frame_color = frame_color.tc_color
    else:
        _frame_color = frame_color

    def _get_line(width: int, add: bool) -> str:
        """
        Formats the bottom half of the frame

        :param width: Length of line
        :param add: Set based on ``frame_size``
        :return: Bottom half of the frame
        """
        n_chars: int = width + 2 if add else width - 2
        if _frame_color:
            return col(f"└{'─' * n_chars}┘", _frame_color)
        else:
            return f"└{'─' * n_chars}┘"

    match frame_size:
        case "full":
            _line = _get_line(width=_term_width, add=False)
        case "dynamic":
            _line = _get_line(width=line_len, add=True)

    return _ansi_pipe_clean(text=_line)


def _format_text(text: list[str | AnyLabeledData | AnyStatus],
                 limit: int = -1,
                 cutoff: bool = True,
                 cutoff_mode: Literal["char", "word", "middle"] = "char",
                 frame_size: Literal["full", "dynamic"] = "full",
                 right_frame: bool = True,
                 center_text: bool = False,
                 longest_line: int = -1,
                 frame_color: Color | None = None) -> list[str]:
    """
    Formats the text for FramedText so it fits in the frame and contains the frame characters

    :param text: Text.
    :param limit: How many characters are allowed on a single line.
    :param cutoff: Whether to shorten text if it exceeds the limit.
    :param cutoff_mode: Mode for cutoff. See documentation for more details.
    :param frame_size: Frame size mode. See documentation for more details.
    :param right_frame: If true, will include the right frame character. Used with ``JoinFrames``
    :param center_text: If True, centers the text inside frame. Ignored if ``frame_size`` is "dynamic"
    :param longest_line: Longest line. Used with dynamic frame size.
    :param frame_color: Color of the frame.
    :return: Formatted string list.
    """

    # Defaults
    if limit <= 0:
        limit = _get_terminal_width()
    if longest_line <= 0:
        longest_line = limit

    _framed_text: list[str] = []
    for line in text:
        _line: str = ''

        # Only strip trailing whitespace for termcolor compact
        line_rstrip: str = line.__str__().rstrip()

        # Remove ANSI for len since it messes with its calculation
        line_no_ansi: str = _remove_ansi(line_rstrip)

        _frame_color: str | tuple[int, int, int] = frame_color.tc_color if frame_color else ''

        def _get_line(_text: str) -> str:
            # Center text
            if frame_size == "full" and center_text:
                _text = _center_text(text=_text, line_len=limit)

            # Get number of spaces required to append
            line_len: int = len(_remove_ansi(text=_text))
            if frame_size == "dynamic":
                space_cnt: int = (longest_line - line_len)
            else:
                space_cnt: int = limit - line_len

            if _frame_color:
                _txt: str = f"{col('│', _frame_color)} {_text}{' ' * space_cnt} "
                if right_frame:
                    _txt += f"{col('│', _frame_color)}"
            else:
                _txt: str = f"│ {_text}{' ' * space_cnt} "
                if right_frame:
                    _txt += '│'

            return _txt

        if ((cutoff and len(line_no_ansi) > limit) or
                (len(line_no_ansi) > limit and isinstance(line, AnyLabeledData))):
            # Cutoff line
            # Also used by LabeledData objects regardless of cutoff's value

            # Do not block ANSI. Instead, use object's shorten value
            if not isinstance(line, str) and isinstance(line, (
                    LabeledData.Path,
                    LabeledData.String,
                    AnyStatus
            )):
                line.cutoff_text(limit=limit, mode=cutoff_mode)
                line_txt: str = line.text_shorten

            elif isinstance(line, AnyLabeledData) and not isinstance(line, str):
                line.cutoff_text(limit=limit)
                line_txt: str = line.text_shorten

            else:
                line_txt: str = ShortenText(text=line_no_ansi, limit=limit, mode=cutoff_mode).__str__()

            _line = _get_line(_text=line_txt)
            _framed_text.append(_ansi_pipe_clean(text=_line))

        else:
            # No edits needed or cutoff is false
            # If cutoff is false, let line get wrapped. (Will break frame formatting!)
            _line = _get_line(_text=line_rstrip)
            _framed_text.append(_ansi_pipe_clean(text=_line))

    return _framed_text


[docs] class FramedText:
[docs] def __init__(self, text: str | AnyLabeledData | AnyStatus | list[str | AnyLabeledData | AnyStatus] = "", title: str = "", cutoff: bool = True, mode: Literal["char", "word", "middle"] = "char", center_text: bool = False, frame_size: Literal["full", "dynamic"] = "full", frame_align: Literal["left", "center", "right"] = "left", frame_color: str | tuple[int, int, int] | Color | None = None, title_color: str | tuple[int, int, int] | Color | None = None, title_attrs: list[str] | None = None, ): """ Creates a string with a customizable frame around it. Frame is based off of prompt_toolkit's show_frame option, but with more customization and designed for print statements. **Cutoff Modes:** - ``char``: Cutoff char at end of text (The quick brown fox jumped ove…) - ``word``: Cutoff word at end of text (The quick brown fox jumped…) - ``middle``: Cutoff chars at middle of text (The quick brown…the lazy dog) **Frame Sizes:** - ``full``: Frame will be the size of the terminal - ``dynamic``: Frame will be the size of the text based on longest line. **Attributes:** All ``termcolor`` attributes are supported: ``"bold"``, ``"dark"``, ``"italic"``, ``"underline"``, ``"reverse"``, ``"concealed"``, ``"strike"`` **NOTE:** ``allow_ansi`` only applies to strings :param text: Text to be framed. Can be a regular string or a LabeledData object :param title: Title of the frame :param mode: Mode for cutoff. "char" for character cutoff, "word" for word cutoff, "middle" for middle cutoff :param frame_color: Color of the frame. Valid colors based off of termcolor's color options: string color code or RGB tuple :param title_color: Color of the title. Valid colors based off of termcolor's color options: string color code or RGB tuple :param cutoff: If true, cut off text if it exceeds terminal width. Default is True (Recommended) :param center_text: If True, centers the text inside frame. Ignored if ``frame_size`` is "dynamic" :param frame_size: Size of the frame. "full" for terminal size, "dynamic" for text size :param frame_align: Align of the frame. Only used if ``frame_size`` is "dynamic" :param title_attrs: Attributes to apply to the title. See list above for valid attributes """ self._title: str = title self._frame_size: Literal["full", "dynamic"] = frame_size self._frame_align: Literal["left", "center", "right"] = frame_align self._center_text: bool = center_text self._longest_line: int = -1 self._term_width: int = _get_terminal_width() self._limit: int = self._term_width - 4 self._frame_color: Color = Color(frame_color) if frame_color else Color() self._title_color: Color = Color(title_color) if title_color else Color() self._title_attrs: list[str] | None = _validate_attrs(attrs=title_attrs) self.framed_text: list[str] = [] self.text: list[str | AnyLabeledData | AnyStatus] = [] # Add data to text if isinstance(text, list): self.text.extend(text) else: self.text.append(text) # If using dynamic frame size, calculate longest line if self._frame_size == "dynamic": for line in self.text: _line_len: int = len(_remove_ansi(text=line.__str__())) if _line_len > self._longest_line: self._longest_line = _line_len # Also check if title is longer than longest line if self._title: _title_len: int = len(_remove_ansi(text=self._title)) if _title_len > self._longest_line: self._longest_line = _title_len + 2 # +2 for spaces in title line # Calculate longest line regardless of frame size # Ensure longest line does not exceed terminal width # Additionally, If the longest line is still its default value (-1), fallback to limit if self._longest_line > self._limit or self._longest_line == -1: self._longest_line = self._limit # Setup title self._title = _shorten_framed_title(title=self._title, limit=self._term_width - 6) _title: str = _format_col_text(text=self._title, color=self._title_color.tc_color, attrs=self._title_attrs) # Setup Title line self.framed_text.append(_frame_draw_top(title=_title, title_len=len(self._title), frame_color=self._frame_color, frame_size=self._frame_size, line_len=self._longest_line if self._frame_size == "dynamic" else -1)) # Format text to insert vertical lines at start and end of each line self.framed_text.extend(_format_text( text=self.text, limit=self._limit, cutoff=cutoff, cutoff_mode=mode, frame_size=self._frame_size, center_text=self._center_text, longest_line=self._longest_line if self._frame_size == "dynamic" else -1, frame_color=self._frame_color )) # Bottom frame: Mirror of top without title self.framed_text.append(_frame_draw_bottom(frame_color=self._frame_color, frame_size=self._frame_size, line_len=self._longest_line if self._frame_size == "dynamic" else -1)) # If dynamic frame size and center text, center every line if self._frame_size == "dynamic": _spaces: int = 0 match self._frame_align: case "center": # Half of limit - line _spaces = (self._limit - self._longest_line) // 2 case "right": # Limit - line _spaces = self._limit - self._longest_line case _: # Default to left _spaces = 0 for i in range(len(self.framed_text)): self.framed_text[i] = f"{' ' * _spaces}{self.framed_text[i]}"
def __str__(self): return '\n'.join(self.framed_text) # ================================================================================================================== # Getters # ================================================================================================================== @property def title(self) -> str: """ :return: Title of the frame """ return self._title @property def longest_line(self) -> int: """ :return: Longest line of the text """ return self._longest_line @property def frame_color(self) -> Color: """ :return: Color of the frame. """ return self._frame_color @property def title_color(self) -> Color: """ :return: Color of the title. """ return self._title_color @property def title_attrs(self) -> list[str] | None: """ :return: Attributes to apply to the title. """ return self._title_attrs