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