Source code for framed_text.frame_chain

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)