Source code for framed_text.framed_text

from typing import Literal

from termcolor import colored as col

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 (
    _validate_color,
    _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
# ------------------------------------------------------------------------------------------
[docs] 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] | 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 = '' match frame_size: case "full": term_width: int = _get_terminal_width() if frame_color and title: # Color and Title start_len, end_len = _calculate_dividers(width=term_width, offset=6, title_len=title_len) _line = 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=term_width, offset=2) line_len: int = start_len + end_len _line = col(f"┌{'─' * line_len}┐", frame_color) elif title: # Title start_len, end_len = _calculate_dividers(width=term_width, offset=6, title_len=title_len) _line = f"┌{'─' * start_len}| {title} |{'─' * end_len}┐" else: # None start_len, end_len = _calculate_dividers(width=term_width, offset=2) line_len: int = start_len + end_len _line = f"┌{'─' * line_len}┐" case "dynamic": if frame_color and title: # Color and Title start_len, end_len = _calculate_dividers(width=line_len, offset=2, title_len=title_len) _line = 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=line_len, offset=2) line_len: int = start_len + end_len _line = col(f"┌{'─' * line_len}┐", frame_color) elif title: # Title start_len, end_len = _calculate_dividers(width=line_len, offset=2, title_len=title_len) _line = f"┌{'─' * start_len}| {title} |{'─' * end_len}┐" else: # None start_len, end_len = _calculate_dividers(width=line_len, offset=2) line_len: int = start_len + end_len _line = f"┌{'─' * line_len}┐" return _ansi_pipe_clean(text=_line)
[docs] def frame_draw_bottom(frame_color: str | tuple[int, int, int] | None = None, frame_size: Literal["full", "dynamic"] = "full", line_len: int = -1) -> 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() match frame_size: case "full": if frame_color: _line = col(f"└{'─' * (_term_width - 2)}┘", frame_color) else: _line = f"└{'─' * (_term_width - 2)}┘" case "dynamic": if frame_color: _line = col(f"└{'─' * (line_len + 2)}┘", frame_color) else: _line = f"└{'─' * (line_len + 2)}┘" return _ansi_pipe_clean(text=_line)
[docs] class FramedText:
[docs] def __init__(self, text: str | AnyLabeledData | AnyStatus | list[str | AnyLabeledData | AnyStatus] = "", title: str = "", frame_color: str | tuple[int, int, int] | None = None, title_color: str | tuple[int, int, int] | None = None, mode: Literal["char", "word", "middle"] = "char", cutoff: bool = True, allow_ansi: bool = False, center_text: bool = False, frame_size: Literal["full", "dynamic"] = "full", 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 allow_ansi: If True, Allows ANSI on long lines (Highly experimental. Use at your own risk). If False, removes all ANSI from non-LabeledData objects on long lines. :param center_text: If True, centers the text inside frame. Centers whole frame if ``frame_size="dynamic"`` :param frame_size: Size of the frame. "full" for terminal size, "dynamic" for text size :param attrs: Attributes to apply to the title. See list above for valid attributes """ # Init public vars self.text = text self.title: str = title self.frame_color: str | tuple[int, int, int] | None = None self.title_color: str | tuple[int, int, int] | None = None self.allow_ansi: bool = allow_ansi self.center_text: bool = center_text self.frame_size: Literal["full", "dynamic"] = frame_size self.attrs: list[str] | None = None # Init private vars self._framed_text: list[str] = [] self._term_width: int = _get_terminal_width() self._limit: int = self._term_width - 4 self._longest_line: int = -1 # Validation if frame_color: _validate_color(color=frame_color) self.frame_color = frame_color if title_color: _validate_color(color=title_color) self.title_color = title_color if attrs: _validate_attrs(attrs=attrs) self.attrs = attrs # 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 # 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, attrs=self.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)) # Format text to insert vertical lines at start and end of each line for line in self.text: _line: str = '' # Only strip trailing whitespace for termcolor compat if any(isinstance(line, T) for T in [AnyLabeledData, AnyStatus, ShortenText]): line_rstrip: str = line.__str__().rstrip() else: line_rstrip: str = line.rstrip() # Remove ANSI for len since it messes with its calculation line_no_ansi: str = _remove_ansi(line_rstrip) if not self.allow_ansi: # Use line_no_ansi except for LabeledData object if ((cutoff and len(line_no_ansi) > self._limit) or (len(line_no_ansi) > self._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 isinstance(line, LabeledData.Path): line.cutoff_text(limit=self._limit, mode=mode) line_txt: str = line.text_shorten elif isinstance(line, LabeledData.String): line.cutoff_text(limit=self._limit, mode=mode) line_txt: str = line.text_shorten elif isinstance(line, AnyLabeledData): line.cutoff_text(limit=self._limit) line_txt: str = line.text_shorten elif isinstance(line, AnyStatus): line.cutoff_text(limit=self._limit, mode=mode) line_txt: str = line.text_shorten else: line_txt: str = ShortenText(text=line_no_ansi, limit=self._limit, mode=mode).__str__() match self.frame_size: case "full": # Center text if self.center_text: line_txt = _center_text(text=line_txt, line_len=self._limit) # Get number of spaces required to append line_len: int = len(_remove_ansi(text=line_txt)) space_cnt: int = self._term_width - line_len if self.frame_color: _line = f"{col('│', self.frame_color)} {line_txt}{col(f"{' ' * (space_cnt - 3)}│", self.frame_color)}" else: _line = f"│ {line_txt}{' ' * (space_cnt - 3)}│" case "dynamic": # Get number of spaces required to append line_len: int = len(_remove_ansi(text=line_txt)) space_cnt: int = self._longest_line - line_len if self.frame_color: _line = f"{col('│', self.frame_color)} {line_txt}{col(f"{' ' * space_cnt} │", self.frame_color)}" else: _line = f"│ {line_txt}{' ' * space_cnt} │" self._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!) match self.frame_size: case "full": # Center text if self.center_text: line_rstrip = _center_text(text=line_rstrip, line_len=self._limit) line_no_ansi = _remove_ansi(text=line_rstrip) # Get number of spaces required to append line_len: int = len(line_no_ansi) space_cnt: int = self._term_width - line_len if self.frame_color: _line = f"{col('│', self.frame_color)} {line_rstrip}{col(f"{' ' * (space_cnt - 3)}│", self.frame_color)}" else: _line = f"│ {line_rstrip}{' ' * (space_cnt - 3)}│" case "dynamic": # Get number of spaces required to append line_len: int = len(line_no_ansi) space_cnt: int = self._longest_line - line_len if self.frame_color: _line = f"{col('│', self.frame_color)} {line_rstrip}{col(f"{' ' * space_cnt} │", self.frame_color)}" else: _line = f"│ {line_rstrip}{' ' * space_cnt} │" self._framed_text.append(_ansi_pipe_clean(text=_line)) else: # Use line_no_ansi for calculations, but line for printing if ((cutoff and len(line_no_ansi) > self._limit) or (len(line_no_ansi) > self._limit and isinstance(line, AnyLabeledData))): # Get cutoff point. # Also used by LabeledData objects regardless of cutoff's value cutoff_point: int = self._get_cutoff_point(len_ansi=len(line_rstrip), len_no_ansi=len(line_no_ansi), term_width=self._term_width, line_no_ansi=line_no_ansi) # Adjust cutoff char if text is a labeled path if isinstance(line, LabeledData.Path): line.cutoff_text(limit=self._limit, mode=mode) line_rstrip = line.text_shorten elif isinstance(line, LabeledData.String): line.cutoff_text(limit=self._limit, mode=mode) line_rstrip = line.text_shorten elif isinstance(line, AnyLabeledData): line.cutoff_text(limit=self._limit) line_rstrip = line.text_shorten elif isinstance(line, AnyStatus): line.cutoff_text(limit=self._limit, mode=mode) line_rstrip: str = line.text_shorten else: line_rstrip: str = ShortenText(text=line_rstrip, limit=cutoff_point - 6, mode=mode).__str__() line_no_ansi = _remove_ansi(line_rstrip) match self.frame_size: case "full": # Center text if self.center_text: line_rstrip = _center_text(text=line_rstrip, line_len=self._limit) line_no_ansi = _remove_ansi(line_rstrip) # Get number of spaces required to append line_len = len(line_no_ansi) space_cnt = self._term_width - line_len if self.frame_color: _line = f"{col('│', self.frame_color)} {line_rstrip}{col(f"{' ' * (space_cnt - 3)}│", self.frame_color)}" else: _line = f"│ {line_rstrip}{' ' * (space_cnt - 3)}│" case "dynamic": # Get number of spaces required to append line_len = len(line_no_ansi) space_cnt = self._longest_line - line_len if self.frame_color: _line = f"{col('│', self.frame_color)} {line_rstrip}{col(f"{' ' * space_cnt} │", self.frame_color)}" else: _line = f"│ {line_rstrip}{' ' * space_cnt} │" self._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!) match self.frame_size: case "full": # Center text if self.center_text: line_rstrip = _center_text(text=line_rstrip, line_len=self._limit) line_no_ansi = _remove_ansi(text=line_rstrip) # Get number of spaces required to append line_len: int = len(line_no_ansi) space_cnt: int = self._term_width - line_len if self.frame_color: _line = f"{col('│', self.frame_color)} {line_rstrip}{col(f"{' ' * (space_cnt - 3)}│", self.frame_color)}" else: _line = f"│ {line_rstrip}{' ' * (space_cnt - 3)}│" self._framed_text.append(_ansi_pipe_clean(text=_line)) case "dynamic": # Get number of spaces required to append line_len: int = len(line_no_ansi) space_cnt: int = self._longest_line - line_len if self.frame_color: _line = f"{col('│', self.frame_color)} {line_rstrip}{col(f"{' ' * space_cnt} │", self.frame_color)}" else: _line = f"│ {line_rstrip}{' ' * space_cnt} │" self._framed_text.append(_ansi_pipe_clean(text=_line)) # 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 dynamic frame size and center text, center every line if self.frame_size == "dynamic" and self.center_text: _spaces: int = (self._limit - self._longest_line) // 2 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) @staticmethod def _get_cutoff_point(len_ansi: int, len_no_ansi: int, term_width: int, line_no_ansi: str) -> int: """ Get cutoff point when line exceeds terminal width :param len_ansi: Length of line with ANSI :param len_no_ansi: Length of line without ANSI :param term_width: Terminal width :param line_no_ansi: Line without ANSI :return: Cutoff point index for line """ # Use length difference as offset len_diff: int = len_ansi - len_no_ansi cutoff_i_no_ansi: int = 0 # Find which char exceeds length in the non-ansi line for i in range(len(line_no_ansi)): if len(line_no_ansi[:i]) > term_width: cutoff_i_no_ansi = i break # Cutoff point in ansi is the cutoff point in non-ansi + length difference return cutoff_i_no_ansi + len_diff