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