from math import floor, ceil
from typing import Literal
from termcolor import colored as col
from framed_text.color import Color
from framed_text.framed_text import FramedText
from framed_text.framed_text import _format_text as _ft_format_text
from framed_text.framed_text import _frame_draw_bottom as _ft_frame_draw_bottom
from framed_text.utils import (
_remove_ansi,
_shorten_framed_title,
_format_col_text,
_calculate_dividers,
_validate_attrs,
_ansi_pipe_clean,
_get_terminal_width,
)
[docs]
class FrameChain:
[docs]
def __init__(self, frames: list[FramedText],
inherit_from: FramedText | None = None,
layout: Literal["vertical", "horizontal"] = "vertical",
columns: int | None = None,
cutoff_mode: Literal["char", "word", "middle"] = "char",
group_title: str | None = None,
show_titles: bool = True,
frame_color: str | tuple[int, int, int] | Color | None = None,
title_color: str | tuple[int, int, int] | Color | None = None,
g_title_color: str | tuple[int, int, int] | Color | None = None,
title_attrs: list[str] | None = None,
g_title_attrs: list[str] | None = None):
"""
Chains multiple ``FramedText`` objects into a single object.
Each ``FramedText``'s text and title are preserved
Providing a ``FramedText`` to ``inherit_from`` will inherit the following to the whole group:
- ``frame_color``
- ``title_color`` (Also applies to ``g_title_color``)
- ``title_attrs`` (Also applies to ``g_title_attrs``)
**Layouts:**
- ``vertical``: Stacks frames vertically
- ``horizontal``: Places frames side by side
**Column Sizes:**
- ``static``: Fixed column size for all columns. Based on 1st frame
- ``even``: Evenly distribute column size (term width / n columns)
**NOTE:** ``cutoff`` is forced enabled for ``JoinFrames``
**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)
:param frames: List of ``FramedText`` objects to group together.
:param inherit_from: ``FramedText`` object to inherit styling variables from.
:param layout: The layout of the frames. Vertical will wrap frames to next line if exceeds terminal width.
:param columns: Will cap the number of columns to this value when using horizontal layout. If blank, will use recommended value.
:param cutoff_mode: Mode for cutoff. "char" for character cutoff, "word" for word cutoff, "middle" for middle cutoff.
:param group_title: If provided, will add a title for the whole group.
:param show_titles: If True, will display each frame's title.
:param frame_color: Color for the frame. Overrides value from ``inherit_from``.
:param title_color: Color for each frame's title. Overrides value from ``inherit_from``.
:param g_title_color: Color for the group title. Overrides value from ``inherit_from``.
:param title_attrs: Attributes for each frame's title. Overrides value from ``inherit_from``.
:param g_title_attrs: Attributes for the group title. Overrides value from ``inherit_from``.
"""
self._frames: list[FramedText] = frames
self._inherit: FramedText | None = inherit_from
self._layout: Literal["vertical", "horizontal"] = layout
self._columns_cap: int = max(0, columns or 0)
self._cutoff_mode: Literal["char", "word", "middle"] = cutoff_mode
self._group_title: str = _remove_ansi(text=group_title) if group_title else ''
self._show_titles: bool = show_titles
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._g_title_color: Color = Color(g_title_color) if g_title_color else Color()
self._title_attrs: list[str] | None = _validate_attrs(attrs=title_attrs)
self._g_title_attrs: list[str] | None = _validate_attrs(attrs=g_title_attrs)
self._term_width: int = _get_terminal_width()
self._frame_limit: int = self._term_width - 4
self.joined_frames: list[str] = []
# Error if no frames provided
if not self._frames:
raise ValueError("At least one frame must be provided.")
# If only one frame was provided, use its output
if len(self._frames) == 1:
self.joined_frames = self._frames[0].framed_text
return
# Handle inheritance
if self._inherit:
if not frame_color:
self._frame_color = self._inherit.frame_color
if not title_color:
self._title_color = self._inherit.title_color
if not g_title_color:
self._g_title_color = self._inherit.title_color
if not title_attrs:
self._title_attrs = self._inherit.title_attrs
if not g_title_attrs:
self._g_title_attrs = self._inherit.title_attrs
# Combine frames
self._combine()
def _combine(self):
"""
Implementation steps:
1. [DONE] Draw group title.
2. Support vertical layout.
a. [DONE] Draw frame (top + bottom).
c. [DONE] Fill in frame text.
3. Support horizontal layout.
a. Draw frames (top + bottom).
b. Fill in frame text.
"""
_is_top: bool = True
# Format group title
if self._group_title != '':
self._group_title = _shorten_framed_title(title=self._group_title, limit=self._term_width - 6)
_group_title: str = _format_col_text(
text=self._group_title,
color=self._g_title_color.tc_color,
attrs=self._g_title_attrs)
self.joined_frames.append(self._frame_draw_top(
top_frame=_is_top,
title=_group_title,
title_len=len(self._group_title),
frame_color=self._frame_color
))
_is_top = False
if self._layout.lower() == "vertical":
for frame in self._frames:
# Draw top of frame
_title: str = _format_col_text(text=frame.title,
color=self._title_color.tc_color,
attrs=self._title_attrs)
self.joined_frames.append(self._frame_draw_top(
top_frame=_is_top,
title=_title if self._show_titles else None,
title_len=len(_remove_ansi(text=_title)),
frame_color=self._frame_color
))
if _is_top:
_is_top = False
# Fill in data
self.joined_frames.extend(_ft_format_text(
text=frame.text,
limit=self._frame_limit,
cutoff=True,
cutoff_mode=self._cutoff_mode,
frame_color=self._frame_color
))
# Draw bottom of frame
self.joined_frames.append(self._frame_draw_bottom(
frame_color=self._frame_color
))
else:
# Get recommended column cap if unspecified
if self._columns_cap == 0:
# Get average line length without whitespace
_total_len: int = 0
_total_cnt: int = 0
for frame in self._frames:
_total_len += sum(len(line.__str__().strip()) for line in frame.text)
_total_cnt += len(frame.text)
_average: int = ceil(_total_len / _total_cnt)
# Get recommended column cap
self._columns_cap = max(1, floor(self._term_width / _average))
# Cap number of columns
_cap: int = min(len(self._frames), self._columns_cap)
# Calculate Limit
_limit: int = floor(self._term_width / _cap)
# Calculate Dimensions
columns: int = floor(self._term_width / _limit)
rows: int = ceil(len(self._frames) / columns)
_frames_left: int = len(self._frames)
for _row in range(rows):
frame: list[str] = []
_cols_in_row: int = min(columns, _frames_left)
# Calculate the amount to increase first frame's limit by
_ff_limit_add: int = (self._term_width - (_limit * _cap))
# -------------------------------------------------------------------------
# Top Frame
# -------------------------------------------------------------------------
_top_frame: str = ''
for c in range(_cols_in_row):
last_frame: bool = c == _cols_in_row - 1
first_frame: bool = c == 0
frame_pos: int = _row * columns + c
# Top of frame
_corners: tuple[str, str] = ('─', '┼') if _row > 0 else ('─', '┬')
_row_limit: int = _limit
# --- Row 1 Checks ---
if (_row, c) == (0, 0) and self._group_title:
# Row 1: First Frame /w Group Title
_corners = ('├', '┬') if first_frame and columns > 1 else ('├', '┤')
_row_limit += _ff_limit_add
elif (_row, c) == (0, _cols_in_row - 1) and self._group_title:
# Row 1: Last Frame /w Group Title
_corners = ('─', '┤')
elif _row == 0 and first_frame:
# Row 1: First Frame
_corners = ('┌', '┬')
_row_limit += _ff_limit_add
elif _row == 0 and last_frame:
# Row 1: Last Frame
_corners = ('─', '┐')
# --- Row 2+ Checks ---
elif first_frame and columns > 1:
# Row 2+: First Frame
_corners = ('├', '┼')
_row_limit += _ff_limit_add
elif first_frame:
# Row 2+: Only Frame in Row
_corners = ('├', '┤')
_row_limit += _ff_limit_add
elif last_frame and c == columns - 1:
# Row 2+: Last Frame, Last Column
_corners = ('─', '┤')
_title: str | None = _format_col_text(
text=self._frames[frame_pos].title,
color=self._title_color.tc_color,
attrs=self._title_attrs) if self._show_titles else None
_top_frame += self._frame_draw_top(
top_frame=True,
corners=_corners,
title=_title,
title_len=len(self._frames[frame_pos].title),
limit=_row_limit,
frame_color=self._frame_color)
if _row > 0:
# Need to fill in bottom frames of previous row
_n_btm_frames: int = max(0, columns - _frames_left)
for c in range(_n_btm_frames):
last_frame: bool = c == _n_btm_frames - 1
_corners: tuple[str, str] = ('─', '┘') if last_frame else ('─', '┴')
_top_frame += self._frame_draw_bottom(
corners=_corners,
limit=_limit,
frame_color=self._frame_color)
frame.append(_top_frame)
# -------------------------------------------------------------------------
# Data
# -------------------------------------------------------------------------
data: list[list] = []
data_fmt: list[list[str]] = []
# Add each frame's text to data
for c in range(_cols_in_row):
frame_pos: int = _row * columns + c
data.append(self._frames[frame_pos].text)
# Find which list has the most lines
_max_lines: int = max(len(d) for d in data)
# Pad all other lists with empty strings
for d in data:
d += [''] * (_max_lines - len(d))
# Format data, store each in a separate list
for c in range(_cols_in_row):
last_frame: bool = c == _cols_in_row - 1
first_frame: bool = c == 0
_row_limit: int = _limit - 4
_row_limit += _ff_limit_add if first_frame else 1
data_fmt.append(_ft_format_text(
text=data[c],
limit=_row_limit,
cutoff=True,
cutoff_mode=self._cutoff_mode,
right_frame=last_frame,
frame_color=self._frame_color
))
# Format each line of data so they are next to each other
for i in range(_max_lines):
_data_line: str = ''
for d in data_fmt:
_data_line += d[i]
frame.append(_data_line)
# -------------------------------------------------------------------------
# Bottom Frame
# -------------------------------------------------------------------------
if _row == rows - 1:
# Only draw bottom frame on last row
_bottom_frame: str = ''
for c in range(_cols_in_row):
last_frame: bool = c == _cols_in_row - 1
first_frame: bool = c == 0
# Top of frame
_corners: tuple[str, str] = ('─', '┴')
_row_limit: int = _limit
if first_frame and _frames_left > 1:
# First Frame, more than 1 frame left
_corners = ('└', '┴')
_row_limit += (self._term_width - (_limit * _cap))
elif _frames_left == 1:
# Last Frame in Stack
_corners = ('└', '┘')
_row_limit += (self._term_width - (_limit * _cap))
elif last_frame:
# Last Frame in Row
_corners = ('─', '┘')
_bottom_frame += self._frame_draw_bottom(
corners=_corners,
limit=_row_limit,
frame_color=self._frame_color)
frame.append(_bottom_frame)
self.joined_frames.append('\n'.join(frame))
_row += 1
_frames_left -= columns
@staticmethod
def _frame_draw_top(top_frame: bool,
corners: tuple[str, str] = ('┌', '┐'),
title: str | None = None,
title_len: int = 0,
limit: int = -1,
frame_color: Color | None = None) -> str:
"""
Draw the top half of the frame
:param top_frame: Whether this is the top frame in the stack (1st one in list)
:param corners: Tuple of characters to use as the corners of the frame
:param title: Optional title
:param title_len: Length of the title
:param limit: Optional limit
:param frame_color: Optional frame color
:return: Top half of the frame
"""
_line: str = ''
if limit <= 0:
limit = _get_terminal_width()
if isinstance(frame_color, Color):
_frame_color = frame_color.tc_color
else:
_frame_color = frame_color
def _get_line(width: int, offset: int, _corners: tuple[str, str]) -> str:
"""
Formats the top half of the frame
:param width: Length of line
:param offset: Offset to width
:param _corners: Characters to use as the corners of the frame
: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"{_corners[0]}{'─' * start_len}| ", _frame_color)}{title}"
f"{col(f" |{'─' * end_len}{_corners[1]}", _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"{_corners[0]}{'─' * _line_len}{_corners[1]}", _frame_color)
elif title:
# Title
start_len, end_len = _calculate_dividers(width=width, offset=offset, title_len=title_len)
return f"{_corners[0]}{'─' * start_len}| {title} |{'─' * end_len}{_corners[1]}"
else:
# None
start_len, end_len = _calculate_dividers(width=width, offset=2)
_line_len: int = start_len + end_len
return f"{_corners[0]}{'─' * _line_len}{_corners[1]}"
_corners: tuple[str, str] = corners
if not top_frame:
_corners = ('├', '┤')
_line = _get_line(width=limit, offset=6, _corners=_corners)
return _ansi_pipe_clean(text=_line)
@staticmethod
def _frame_draw_bottom(limit: int = -1,
corners: tuple[str, str] | None = None,
frame_color: Color | None = None) -> str:
"""
Draw the bottom half of the frame
:param limit: Optional limit
:param corners: Tuple of characters to use as the corners of the frame
:param frame_color: Optional frame color
:return: Bottom 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, _corners: tuple[str, str]) -> str:
"""
Formats the bottom half of the frame
:param width: Length of line
:param _corners: Tuple of characters to use as the corners of the frame
:return: Bottom half of the frame
"""
n_chars: int = width - 2
if _frame_color:
return col(f"{_corners[0]}{'─' * n_chars}{_corners[1]}", _frame_color)
else:
return f"{_corners[0]}{'─' * n_chars}{_corners[1]}"
if not corners:
# Use logic from FramedText
_line = _ft_frame_draw_bottom(frame_color=_frame_color, line_len=limit)
else:
_line = _get_line(width=limit, _corners=corners)
return _ansi_pipe_clean(text=_line)
def __str__(self):
return '\n'.join(self.joined_frames)