Skip to content

Internals

The pieces the viewer composes. You rarely need these directly, but they are public, typed, and tested on their own.

ANSI decoding

AnsiEscapeHandler keeps terminal style state (colors, bold, italic, reverse, …) across parse() calls, exactly like a terminal: a \x1b[31m on one append still colors the next append until \x1b[0m resets it. Repeat counts are clamped at 10,000 and digit runs capped, so hostile input like \x1b[999999999C cannot freeze the UI. setDefaultColors() changes what reset restores (this is how themes work); setAnsi8Color() overrides individual palette entries.

Search engine

SearchHandler implements highlight search over the QTextDocument (plain or QRegularExpression, optional case sensitivity) with wrapping next_match() / prev_match() navigation, plus the hide-non-matching-blocks filter. Defenses: invalid regex falls back to a literal search instead of raising, and patterns over 500 characters are rejected to blunt ReDoS. Newly appended blocks are filtered on arrival via update_new_blocks(), so live streams respect an active filter.

Gutter

LineNumberArea paints line numbers, bookmark markers, and theme-aware gutter colors delegated from the viewer. Clicking it toggles the bookmark on that line (toggleBookmarkAtY() refuses invisible blocks).

ansi_text_viewer.ansi_escape_handler.AnsiEscapeHandler

AnsiEscapeHandler()

Stateful ANSI SGR and cursor-sequence decoder.

Keeps the current style (colors, bold, ...) across parse calls, like a terminal. Repeat counts are clamped to blunt DoS input.

Examples:

>>> handler = AnsiEscapeHandler()
>>> actions = handler.parse("\x1b[1mbold")
>>> actions[0][0]
'text'

Create a handler with default terminal styling.

Source code in ansi_text_viewer/ansi_escape_handler.py
def __init__(self):
    """Create a handler with default terminal styling."""
    self._custom_8: dict[tuple[int, bool], QColor] = {}
    self.default_fg = QColor(255, 255, 255)
    self.default_bg = QColor(0, 0, 0)
    self.reset()

_custom_8 instance-attribute

_custom_8 = {}

default_fg instance-attribute

default_fg = QColor(255, 255, 255)

default_bg instance-attribute

default_bg = QColor(0, 0, 0)

reset

reset()

Restore default colors and clear all text attributes.

Source code in ansi_text_viewer/ansi_escape_handler.py
def reset(self):
    """Restore default colors and clear all text attributes."""
    self.fg = QColor(self.default_fg)
    self.bg = QColor(self.default_bg)
    self.bold = False
    self.dim = False
    self.italic = False
    self.underline = False
    self.blink = False
    self.reverse = False
    self.strikethrough = False

parse

parse(text)

Parse text with escape sequences into display actions.

Parameters:

Name Type Description Default
text str

Raw text that may contain ESC[ sequences.

required

Returns:

Type Description
list[tuple]

List of ("text", str, QTextCharFormat) tuples plus cursor

list[tuple]

actions such as ("cursor_up", n) or ("erase_line", mode).

Examples:

>>> handler = AnsiEscapeHandler()
>>> handler.parse("plain")
[('text', 'plain', <...>)]
Source code in ansi_text_viewer/ansi_escape_handler.py
def parse(self, text: str) -> list[tuple]:
    r"""Parse text with escape sequences into display actions.

    Args:
        text: Raw text that may contain ``ESC[`` sequences.

    Returns:
        List of ``("text", str, QTextCharFormat)`` tuples plus cursor
        actions such as ``("cursor_up", n)`` or ``("erase_line", mode)``.

    Examples:
        >>> handler = AnsiEscapeHandler()
        >>> handler.parse("plain")
        [('text', 'plain', <...>)]
    """
    actions: list[tuple] = []
    i = 0
    n = len(text)

    while i < n:
        if text[i] != "\x1b":
            start = i
            while i < n and text[i] != "\x1b":
                i += 1
            actions.append(("text", text[start:i], self.__get_format()))
            continue

        # \x1b[ ...
        if i + 1 < n and text[i + 1] == "[":
            i += 2

            question_mark = False
            if i < n and text[i] == "?":
                question_mark = True
                i += 1

            params = []
            num = ""

            def _num(n: str) -> int:
                # Clamp huge repeat counts (e.g. \x1b[999999999C) to avoid
                # cursor-movement DoS loops downstream.
                try:
                    return min(int(n), 10000) if n else 0
                except ValueError:
                    return 0

            while i < n:
                c = text[i]
                if c.isdigit():
                    # Cap digit run early so 1MB of digits can't build a giant int.
                    if len(num) < 5:
                        num += c
                    elif len(num) == 5:
                        num = "10000"
                elif c == ";":
                    params.append(_num(num))
                    num = ""
                else:
                    if num:
                        params.append(_num(num))

                    if c == "m":
                        if not params and not num:  # e.g., \x1b[m
                            self.__apply_sgr([])
                        else:
                            self.__apply_sgr(params)
                    elif c == "A":
                        actions.append(("cursor_up", params[0] if params else 1))
                    elif c == "B":
                        actions.append(("cursor_down", params[0] if params else 1))
                    elif c == "C":
                        actions.append(
                            ("cursor_forward", params[0] if params else 1)
                        )
                    elif c == "D":
                        actions.append(("cursor_back", params[0] if params else 1))
                    elif c == "G":
                        actions.append(("cursor_col", params[0] if params else 1))
                    elif c == "H" or c == "f":
                        row = params[0] if len(params) > 0 else 1
                        col = params[1] if len(params) > 1 else 1
                        actions.append(("cursor_pos", row, col))
                    elif c == "J":
                        actions.append(("erase_screen", params[0] if params else 0))
                    elif c == "K":
                        actions.append(("erase_line", params[0] if params else 0))
                    elif c == "h" and question_mark:
                        if params and params[0] == 25:
                            actions.append(("show_cursor",))
                    elif c == "l" and question_mark:
                        if params and params[0] == 25:
                            actions.append(("hide_cursor",))

                    i += 1
                    break
                i += 1
            continue

        i += 1

    return actions

__get_format

__get_format()
Source code in ansi_text_viewer/ansi_escape_handler.py
def __get_format(self):
    fmt = QTextCharFormat()

    fg = self.fg
    bg = self.bg

    if self.dim:
        fg = QColor(
            max(0, fg.red() // 2),
            max(0, fg.green() // 2),
            max(0, fg.blue() // 2),
        )

    if self.reverse:
        fmt.setForeground(bg)
        fmt.setBackground(fg)
    else:
        fmt.setForeground(fg)
        fmt.setBackground(bg)

    if self.bold:
        fmt.setFontWeight(QFont.Weight.Bold)
    if self.italic:
        fmt.setFontItalic(True)
    if self.underline:
        fmt.setFontUnderline(True)
    if self.strikethrough:
        fmt.setFontStrikeOut(True)

    return fmt

__apply_sgr

__apply_sgr(params)
Source code in ansi_text_viewer/ansi_escape_handler.py
def __apply_sgr(self, params: list):
    if not params:
        self.reset()
        return

    i = 0
    while i < len(params):
        code = params[i]
        i += 1

        if code == 0:
            self.reset()
        elif code == 1:
            self.bold = True
        elif code == 2:
            self.dim = True
        elif code == 3:
            self.italic = True
        elif code == 4:
            self.underline = True
        elif code == 5:
            self.blink = True
        elif code == 7:
            self.reverse = True
        elif code == 9:
            self.strikethrough = True
        elif code == 22:
            self.bold = False
            self.dim = False
        elif code == 23:
            self.italic = False
        elif code == 24:
            self.underline = False
        elif code == 25:
            self.blink = False
        elif code == 27:
            self.reverse = False
        elif code == 29:
            self.strikethrough = False

        # Standard colors
        elif 30 <= code <= 37:
            self.fg = self.__ansi8_color(code - 30)
        elif 40 <= code <= 47:
            self.bg = self.__ansi8_color(code - 40)
        elif 90 <= code <= 97:
            self.fg = self.__ansi8_color(code - 90, bright=True)
        elif 100 <= code <= 107:
            self.bg = self.__ansi8_color(code - 100, bright=True)

        # 256 colors
        elif code == 38 and i < len(params) and params[i] == 5:
            i += 2
            if i - 1 < len(params):
                self.fg = self.__color_256(params[i - 1])
        elif code == 48 and i < len(params) and params[i] == 5:
            i += 2
            if i - 1 < len(params):
                self.bg = self.__color_256(params[i - 1])

        # Truecolor RGB
        elif code == 38 and i + 3 < len(params) and params[i] == 2:
            i += 4
            r, g, b = params[i - 3 : i]
            self.fg = QColor(r, g, b)
        elif code == 48 and i + 3 < len(params) and params[i] == 2:
            i += 4
            r, g, b = params[i - 3 : i]
            self.bg = QColor(r, g, b)

setDefaultColors

setDefaultColors(fg=None, bg=None)

Override the colors used by SGR reset.

Parameters:

Name Type Description Default
fg QColor | None

Default foreground, or None to keep the current one.

None
bg QColor | None

Default background, or None to keep the current one.

None

Examples:

>>> handler.setDefaultColors(QColor(0, 0, 0), QColor(255, 255, 255))
Source code in ansi_text_viewer/ansi_escape_handler.py
def setDefaultColors(self, fg: QColor | None = None, bg: QColor | None = None):
    """Override the colors used by SGR reset.

    Args:
        fg: Default foreground, or None to keep the current one.
        bg: Default background, or None to keep the current one.

    Examples:
        >>> handler.setDefaultColors(QColor(0, 0, 0), QColor(255, 255, 255))
    """
    if fg is not None:
        self.default_fg = QColor(fg)
    if bg is not None:
        self.default_bg = QColor(bg)
    self.reset()

defaultColors

defaultColors()

Return the current (foreground, background) defaults.

Source code in ansi_text_viewer/ansi_escape_handler.py
def defaultColors(self) -> tuple[QColor, QColor]:
    """Return the current ``(foreground, background)`` defaults."""
    return (QColor(self.default_fg), QColor(self.default_bg))

setAnsi8Color

setAnsi8Color(idx, color, bright=False)

Override one ANSI base color.

Parameters:

Name Type Description Default
idx int

Base color 0..7.

required
color QColor

Replacement color.

required
bright bool

Whether it applies to the bright variant.

False
Source code in ansi_text_viewer/ansi_escape_handler.py
def setAnsi8Color(self, idx: int, color: QColor, bright: bool = False):
    """Override one ANSI base color.

    Args:
        idx: Base color 0..7.
        color: Replacement color.
        bright: Whether it applies to the bright variant.
    """
    self._custom_8[(idx % 8, bool(bright))] = QColor(color)

resetPalette

resetPalette()

Drop all custom palette overrides.

Source code in ansi_text_viewer/ansi_escape_handler.py
def resetPalette(self):
    """Drop all custom palette overrides."""
    self._custom_8.clear()

palette

palette()

Return custom (index, bright) -> QColor overrides.

Source code in ansi_text_viewer/ansi_escape_handler.py
def palette(self) -> dict:
    """Return custom ``(index, bright) -> QColor`` overrides."""
    return dict(self._custom_8)

__ansi8_color

__ansi8_color(idx, bright=False)
Source code in ansi_text_viewer/ansi_escape_handler.py
def __ansi8_color(self, idx: int, bright: bool = False):
    key = (idx % 8, bool(bright))
    if key in self._custom_8:
        return QColor(self._custom_8[key])
    palette = [
        QColor(0, 0, 0),
        QColor(170, 0, 0),
        QColor(0, 170, 0),
        QColor(170, 170, 0),
        QColor(0, 0, 170),
        QColor(170, 0, 170),
        QColor(0, 170, 170),
        QColor(170, 170, 170),
    ]
    c = palette[idx % 8]
    if bright:
        c = QColor(
            min(c.red() + 85, 255),
            min(c.green() + 85, 255),
            min(c.blue() + 85, 255),
        )
    return c

__color_256

__color_256(idx)
Source code in ansi_text_viewer/ansi_escape_handler.py
def __color_256(self, idx: int):
    if idx < 16:
        return self.__ansi8_color(idx % 8, bright=idx >= 8)

    if idx < 232:  # 6x6x6 cube
        idx -= 16
        r = (idx // 36) * 51
        g = ((idx // 6) % 6) * 51
        b = (idx % 6) * 51
        return QColor(r, g, b)

    # Grayscale
    gray = 8 + (idx - 232) * 10
    return QColor(gray, gray, gray)

ansi_text_viewer.search_handler.SearchHandler

SearchHandler(viewer)

Finds matches and hides non-matching blocks in the viewer.

Create a handler bound to viewer.

Source code in ansi_text_viewer/search_handler.py
def __init__(self, viewer):
    """Create a handler bound to *viewer*."""
    self.viewer = viewer
    self.search_highlight_color = QColor(255, 255, 0, 100)  # Translucent yellow
    self.active_search_color = QColor(255, 165, 0, 150)  # Translucent orange
    self.search_selections: list[QTextEdit.ExtraSelection] = []
    self.current_match_index = -1
    self.filter_query = ""
    self.filter_use_regex = False
    self.filter_match_case = False

viewer instance-attribute

viewer = viewer

search_highlight_color instance-attribute

search_highlight_color = QColor(255, 255, 0, 100)

active_search_color instance-attribute

active_search_color = QColor(255, 165, 0, 150)

search_selections instance-attribute

search_selections = []

current_match_index instance-attribute

current_match_index = -1

filter_query instance-attribute

filter_query = ''

filter_use_regex instance-attribute

filter_use_regex = False

filter_match_case instance-attribute

filter_match_case = False
highlight_search(query, use_regex=False, match_case=False)

Highlight every match and jump to the first one.

Parameters:

Name Type Description Default
query str

Text or pattern (empty clears the highlight).

required
use_regex bool

Treat query as a regular expression.

False
match_case bool

Case-sensitive matching.

False

Returns:

Type Description
int

The number of matches found.

Source code in ansi_text_viewer/search_handler.py
def highlight_search(
    self, query: str, use_regex: bool = False, match_case: bool = False
) -> int:
    """Highlight every match and jump to the first one.

    Args:
        query: Text or pattern (empty clears the highlight).
        use_regex: Treat *query* as a regular expression.
        match_case: Case-sensitive matching.

    Returns:
        The number of matches found.
    """
    if not query:
        self.clear_search_highlight()
        return 0

    selections: list[QTextEdit.ExtraSelection] = []
    doc: QTextDocument = self.viewer.document()
    cursor: QTextCursor = QTextCursor(doc)

    flags = QTextDocument.FindFlag(0)
    if match_case:
        flags |= QTextDocument.FindFlag.FindCaseSensitively

    while True:
        if use_regex:
            regex = QRegularExpression(query)
            if not regex.isValid():
                logger.debug(
                    "highlight_search: invalid regex %r: %s",
                    query,
                    regex.errorString(),
                )
                break
            if match_case:
                regex.setPatternOptions(
                    QRegularExpression.PatternOption.NoPatternOption
                )
            else:
                regex.setPatternOptions(
                    QRegularExpression.PatternOption.CaseInsensitiveOption
                )
            cursor = doc.find(regex, cursor, flags)
        else:
            cursor = doc.find(query, cursor, flags)

        if cursor.isNull():
            break

        selection = QTextEdit.ExtraSelection()

        selection.cursor = cursor
        selections.append(selection)

    self.search_selections = selections
    self.current_match_index = 0 if selections else -1
    self.__update_search_highlights()

    return len(selections)

__update_search_highlights

__update_search_highlights()
Source code in ansi_text_viewer/search_handler.py
def __update_search_highlights(self):
    if not self.search_selections:
        self.viewer.setExtraSelections([])
        return

    selections = []
    for i, selection in enumerate(self.search_selections):
        s = QTextEdit.ExtraSelection()
        s.cursor = selection.cursor
        fmt = QTextCharFormat()
        if i == self.current_match_index:
            fmt.setBackground(self.active_search_color)
        else:
            fmt.setBackground(self.search_highlight_color)
        s.format = fmt
        selections.append(s)

    self.viewer.setExtraSelections(selections)

    if 0 <= self.current_match_index < len(self.search_selections):
        active_cursor = self.search_selections[self.current_match_index].cursor
        self.viewer.setTextCursor(active_cursor)
        self.viewer.ensureCursorVisible()

next_match

next_match()

Jump to the next match, wrapping around.

Returns:

Type Description
int

The 1-based index of the now-active match.

Source code in ansi_text_viewer/search_handler.py
def next_match(self) -> int:
    """Jump to the next match, wrapping around.

    Returns:
        The 1-based index of the now-active match.
    """
    if not self.search_selections:
        return 0
    self.current_match_index = (self.current_match_index + 1) % len(
        self.search_selections
    )
    self.__update_search_highlights()
    return self.current_match_index + 1

prev_match

prev_match()

Jump to the previous match, wrapping around.

Returns:

Type Description
int

The 1-based index of the now-active match.

Source code in ansi_text_viewer/search_handler.py
def prev_match(self) -> int:
    """Jump to the previous match, wrapping around.

    Returns:
        The 1-based index of the now-active match.
    """
    if not self.search_selections:
        return 0
    if self.current_match_index <= 0:
        self.current_match_index = len(self.search_selections) - 1
    else:
        self.current_match_index -= 1
    self.__update_search_highlights()
    return self.current_match_index + 1

clear_search_highlight

clear_search_highlight()

Clear all search highlights and selection state.

Source code in ansi_text_viewer/search_handler.py
def clear_search_highlight(self):
    """Clear all search highlights and selection state."""
    self.search_selections = []
    self.current_match_index = -1
    self.viewer.setExtraSelections([])

setSearchHighlightColor

setSearchHighlightColor(color, active_color=None)

Set match backgrounds.

Parameters:

Name Type Description Default
color QColor

Background for inactive matches.

required
active_color QColor | None

Background for the active match, or None to keep it.

None
Source code in ansi_text_viewer/search_handler.py
def setSearchHighlightColor(
    self, color: QColor, active_color: QColor | None = None
):
    """Set match backgrounds.

    Args:
        color: Background for inactive matches.
        active_color: Background for the active match, or None to keep it.
    """
    self.search_highlight_color = color
    if active_color:
        self.active_search_color = active_color

apply_filter

apply_filter(query, use_regex=False, match_case=False)

Hide blocks that do not match query.

Parameters:

Name Type Description Default
query str

Text or pattern (empty restores every block).

required
use_regex bool

Treat query as a regular expression.

False
match_case bool

Case-sensitive matching.

False
Source code in ansi_text_viewer/search_handler.py
def apply_filter(
    self, query: str, use_regex: bool = False, match_case: bool = False
):
    """Hide blocks that do not match *query*.

    Args:
        query: Text or pattern (empty restores every block).
        use_regex: Treat *query* as a regular expression.
        match_case: Case-sensitive matching.
    """
    self.filter_query = query
    self.filter_use_regex = use_regex
    self.filter_match_case = match_case

    doc = self.viewer.document()
    self.filter_blocks(doc.firstBlock())

    doc.documentLayout().requestUpdate()
    self.viewer.viewport().update()

filter_blocks

filter_blocks(start_block)

Apply the current filter from start_block to the document end.

Source code in ansi_text_viewer/search_handler.py
def filter_blocks(self, start_block):
    """Apply the current filter from *start_block* to the document end."""
    if not self.filter_query:
        while start_block.isValid():
            start_block.setVisible(True)
            start_block = start_block.next()
        return

    import re

    pattern = None
    query = self.filter_query
    if self.filter_use_regex:
        if len(query) > 500:
            logger.debug("filter: truncated %d-char regex", len(query))
            query = query[:500]
            self.filter_query = query
        flags = 0 if self.filter_match_case else re.IGNORECASE
        try:
            pattern = re.compile(query, flags)
        except re.error as exc:
            logger.debug(
                "filter: invalid regex %r (%s), literal fallback", query, exc
            )
            pattern = re.compile(re.escape(query), flags)
    else:
        if not self.filter_match_case:
            query = query.lower()

    while start_block.isValid():
        text = start_block.text()
        if self.filter_use_regex:
            match = pattern is not None and bool(pattern.search(text))
        else:
            match = query in (text if self.filter_match_case else text.lower())

        start_block.setVisible(match)
        start_block = start_block.next()

update_new_blocks

update_new_blocks(start_block_number)

Filter blocks appended after start_block_number.

Parameters:

Name Type Description Default
start_block_number int

First block that has not been filtered yet.

required
Source code in ansi_text_viewer/search_handler.py
def update_new_blocks(self, start_block_number: int):
    """Filter blocks appended after *start_block_number*.

    Args:
        start_block_number: First block that has not been filtered yet.
    """
    if not self.filter_query:
        return
    doc = self.viewer.document()
    block = doc.findBlockByNumber(start_block_number)
    if block.isValid():
        self.filter_blocks(block)
        doc.documentLayout().requestUpdate()

ansi_text_viewer.line_number_area.LineNumberArea

LineNumberArea(viewer)

Bases: QWidget

Narrow widget beside the viewer for numbers and bookmark clicks.

Create the gutter for viewer.

Source code in ansi_text_viewer/line_number_area.py
def __init__(self, viewer):
    """Create the gutter for *viewer*."""
    super().__init__(viewer)
    self.viewer = viewer
    self.setToolTip("Click to bookmark / unbookmark line")

viewer instance-attribute

viewer = viewer

sizeHint

sizeHint()

Return the gutter width requested by the viewer.

Source code in ansi_text_viewer/line_number_area.py
def sizeHint(self):
    """Return the gutter width requested by the viewer."""
    return QSize(self.viewer.lineNumberAreaWidth(), 0)

paintEvent

paintEvent(event)

Delegate painting to the viewer.

Source code in ansi_text_viewer/line_number_area.py
def paintEvent(self, event: QPaintEvent):
    """Delegate painting to the viewer."""
    self.viewer.lineNumberAreaPaintEvent(event)

mousePressEvent

mousePressEvent(event)

Toggle the bookmark of the clicked line.

Source code in ansi_text_viewer/line_number_area.py
def mousePressEvent(self, event):
    """Toggle the bookmark of the clicked line."""
    if event.button() == Qt.MouseButton.LeftButton:
        if self.viewer.toggleBookmarkAtY(int(event.position().y())) >= 0:
            event.accept()
            return
    super().mousePressEvent(event)