Skip to content

Viewer

The main widget. One import, one class, sensible defaults — everything works with zero configuration and every behavior has a setter/getter pair.

from ansi_text_viewer import AnsiTextViewer

viewer = AnsiTextViewer()
viewer.appendAnsiText("\x1b[32m[INFO]\x1b[0m server up\n")
Feature What lives here
Appending Write, replace, clear, pause, clear-on-start
Non-blocking bursts Async queue, progress, cancel
Search & filter Highlight, navigate, hide lines
Limits Memory caps
Fonts & zoom Families, sizes, system font
Layout Wrap, scroll, line numbers
Highlight toggles Timestamp, levels, syntax, custom rules
Current line Cursor-line highlight
Links Clickable URLs and file paths
Copy, export, sessions Clipboard, files, stats
Bookmarks Mark, navigate, persist
Theme & colors Light/dark/auto, palettes, defaults

Shortcuts

Keys Action
Ctrl+F Focus search (findRequested)
F3 / Shift+F3 Next / previous match
Esc Clear search highlight
Ctrl + wheel Zoom in / out

Shortcuts are application-wide, so F3 works while typing in a search box.

Signals

The widget talks back through Qt signals — no polling needed:

Signal Emitted when
bookmarksChanged(list) Any bookmark is added, removed, or cleared (payload: block numbers)
findRequested() Ctrl+F is pressed — focus your search field
nextRequested() / prevRequested() F3 / Shift+F3 is pressed
asyncAppendProgress(int, int) Each async chunk lands (done, total lines)
asyncAppendFinished() The async queue drains
viewer.bookmarksChanged.connect(refresh_bookmark_list)
viewer.findRequested.connect(search_box.setFocus)

Appending content

appendAnsiText() parses ANSI SGR colors, 256-color and truecolor codes, plus cursor movement and erase sequences. A carriage return overwrites the current line instead of appending, so progress bars render in place:

viewer.appendAnsiText("Downloading... 10%\rDownloading... 100%\n")

setAnsiText() replaces everything, insertAnsiText() writes at the cursor, and clear() resets text, ANSI state, search, bookmarks and any queued async chunks. pause_stream() buffers incoming text (capped at 5000 entries) until resume_stream() flushes it. Pair setClearOnStart(True) with beginNewStream() to wipe the view whenever a new stream starts.

Append ANSI text; \r overwrites the current line (progress bars).

Source code in ansi_text_viewer/ansi_text_viewer.py
def appendAnsiText(self, text: str):
    r"""Append ANSI text; ``\r`` overwrites the current line (progress bars)."""
    if not isinstance(text, str):
        raise TypeError(f"appendAnsiText expects str, got {type(text).__name__}")
    if self.__is_paused:
        # Bound pause buffer so a hostile stream can't eat all RAM.
        self.__stream_buffer.append(text)
        if len(self.__stream_buffer) > 5000:
            dropped = len(self.__stream_buffer) - 5000
            del self.__stream_buffer[:dropped]
            logger.debug("pause buffer full: dropped %d oldest lines", dropped)
    else:
        self.__appendAnsiTextInternal(text)

Replace all content (clears bookmarks and search first).

Source code in ansi_text_viewer/ansi_text_viewer.py
def setAnsiText(self, text: str):
    """Replace all content (clears bookmarks and search first)."""
    self.clear()
    self.appendAnsiText(text)

Insert ANSI text at the current cursor position.

Source code in ansi_text_viewer/ansi_text_viewer.py
def insertAnsiText(self, text: str):
    """Insert ANSI text at the current cursor position."""
    actions = self.__ansi_escape_handler.parse(text)
    cursor = self.textCursor()
    self.__execute_actions(actions, cursor)

Clear content, ANSI state, search, bookmarks and queued async.

Source code in ansi_text_viewer/ansi_text_viewer.py
def clear(self):
    """Clear content, ANSI state, search, bookmarks and queued async."""
    super().clear()
    self.__ansi_escape_handler.reset()
    self.__search_extra = []
    # Bookmarks live on blocks, so clearing the document drops them.
    self.__bookmark_count = 0
    if self.__async_queue:
        logger.debug(
            "clear: dropped %d queued async chunks", len(self.__async_queue)
        )
        self.__async_queue.clear()
        self.__async_total = 0
        self.__async_done = 0
    self.__async_queue.clear()
    self.__async_total = 0
    self.__async_done = 0
    self.__refresh_extra_selections()
    # Keep SearchHandler state in sync without triggering another refresh loop.
    try:
        self.__search_handler.search_selections = []
        self.__search_handler.current_match_index = -1
    except Exception:
        pass

Buffer appended text instead of displaying it.

Source code in ansi_text_viewer/ansi_text_viewer.py
def pause_stream(self):
    """Buffer appended text instead of displaying it."""
    self.__is_paused = True

Flush buffered text and resume live display.

Source code in ansi_text_viewer/ansi_text_viewer.py
def resume_stream(self):
    """Flush buffered text and resume live display."""
    self.__is_paused = False
    if self.__stream_buffer:
        buffer_text = "".join(self.__stream_buffer)
        self.__stream_buffer.clear()
        self.__appendAnsiTextInternal(buffer_text)

Clear first if Clear-on-Start is on. Demo calls this on Start.

Source code in ansi_text_viewer/ansi_text_viewer.py
def beginNewStream(self):
    """Clear first if Clear-on-Start is on. Demo calls this on Start."""
    if self.__clear_on_start:
        self.clear()

Clear the view when :meth:beginNewStream starts a stream.

Source code in ansi_text_viewer/ansi_text_viewer.py
def setClearOnStart(self, enabled: bool):
    """Clear the view when :meth:`beginNewStream` starts a stream."""
    self.__clear_on_start = _to_bool(enabled)

Return True when new streams start cleared.

Source code in ansi_text_viewer/ansi_text_viewer.py
def isClearOnStart(self) -> bool:
    """Return True when new streams start cleared."""
    return self.__clear_on_start

Non-blocking bursts

appendAnsiTextAsync() splits text into line chunks inserted one per event-loop turn, so a 100k-line burst never freezes the UI. Chunking reuses the exact sync path — ANSI state, \r handling, timestamps and highlighting are byte-identical (covered by test_async_matches_sync). Call it on the GUI thread; from worker threads, connect a signal to it — Qt queues the call across threads automatically.

viewer.appendAnsiTextAsync(huge_log, chunk_lines=500)
viewer.asyncAppendProgress.connect(
    lambda done, total: bar.setValue(100 * done // total)
)
viewer.asyncAppendFinished.connect(lambda: status.showMessage("done"))

hasPendingAppends() reports queued work and cancelAsyncAppends() drops it (the in-flight chunk still finishes). clear() also drops the queue, so cleared output can never be resurrected by pending chunks.

Queue text for non-blocking appends; returns immediately.

Large bursts are split into line chunks inserted one per event-loop turn, so the UI never freezes. Order, ANSI state, timestamps and highlighting match the sync path. Must be called on the GUI thread; from worker threads, emit a signal connected to this slot instead.

Parameters:

Name Type Description Default
text str

Text to append (may contain newlines and ANSI codes).

required
chunk_lines int

Lines per event-loop turn, clamped to 1..10000.

500

Raises:

Type Description
TypeError

When text is not a string.

Examples:

>>> viewer.appendAnsiTextAsync(huge_log)
>>> viewer.asyncAppendFinished.connect(on_done)
Source code in ansi_text_viewer/ansi_text_viewer.py
def appendAnsiTextAsync(self, text: str, chunk_lines: int = 500) -> None:
    """Queue text for non-blocking appends; returns immediately.

    Large bursts are split into line chunks inserted one per event-loop
    turn, so the UI never freezes. Order, ANSI state, timestamps and
    highlighting match the sync path. Must be called on the GUI thread;
    from worker threads, emit a signal connected to this slot instead.

    Args:
        text: Text to append (may contain newlines and ANSI codes).
        chunk_lines: Lines per event-loop turn, clamped to 1..10000.

    Raises:
        TypeError: When *text* is not a string.

    Examples:
        >>> viewer.appendAnsiTextAsync(huge_log)
        >>> viewer.asyncAppendFinished.connect(on_done)
    """
    if not isinstance(text, str):
        raise TypeError(
            f"appendAnsiTextAsync expects str, got {type(text).__name__}"
        )
    requested_chunks = chunk_lines
    try:
        want = int(chunk_lines)
    except (TypeError, ValueError):
        want = 500
    chunk_lines = max(1, min(want, 10000))
    if chunk_lines != want:
        logger.debug(
            "appendAnsiTextAsync: clamped chunk_lines %r to %d",
            requested_chunks,
            chunk_lines,
        )
    if not text:
        return
    segments = text.splitlines(keepends=True)
    # Never split right after a bare carriage return: the next segment
    # overwrites the current line, so it must stay in the same chunk.
    lines: list[str] = []
    for seg in segments:
        if lines and lines[-1].endswith("\r"):
            lines[-1] += seg
        else:
            lines.append(seg)
    for i in range(0, len(lines), chunk_lines):
        self.__async_queue.append("".join(lines[i : i + chunk_lines]))
    self.__async_total += len(lines)
    self.__async_active = True
    self.__schedule_async_pump()

Return True while async chunks are still queued.

Source code in ansi_text_viewer/ansi_text_viewer.py
def hasPendingAppends(self) -> bool:
    """Return True while async chunks are still queued."""
    return bool(self.__async_queue)

Drop queued async chunks; the in-flight chunk still finishes.

Returns:

Type Description
int

The number of dropped chunks.

Source code in ansi_text_viewer/ansi_text_viewer.py
def cancelAsyncAppends(self) -> int:
    """Drop queued async chunks; the in-flight chunk still finishes.

    Returns:
        The number of dropped chunks.
    """
    dropped = len(self.__async_queue)
    self.__async_queue.clear()
    self.__async_total = 0
    self.__async_done = 0
    self.__async_active = False
    if dropped:
        logger.debug("cancelAsyncAppends: dropped %d chunks", dropped)
    return dropped

Search & filter

highlight_search() highlights every match and jumps to the first, returning the count. Over-long patterns are rejected to blunt ReDoS, and invalid regex falls back safely instead of raising:

n = viewer.highlight_search(r"ERR-\d+", use_regex=True)
viewer.next_match()   # F3
viewer.prev_match()   # Shift+F3

filter_search() hides non-matching blocks (line numbers and bookmarks track the survivors); an empty query restores everything. Newly appended lines are filtered on arrival, and match colors are tunable with setSearchHighlightColor().

Highlight every match and jump to the first one.

Over-long regexes are rejected to blunt ReDoS from untrusted input.

Parameters:

Name Type Description Default
query str

Text or pattern to find (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.

Examples:

>>> viewer.setAnsiText("foo bar foo")
>>> viewer.highlight_search("foo")
2
Source code in ansi_text_viewer/ansi_text_viewer.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.

    Over-long regexes are rejected to blunt ReDoS from untrusted input.

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

    Returns:
        The number of matches found.

    Examples:
        >>> viewer.setAnsiText("foo bar foo")
        >>> viewer.highlight_search("foo")
        2
    """
    # Bound regex length to blunt ReDoS from untrusted search input.
    if use_regex and len(query or "") > 500:
        logger.debug("highlight_search: rejected %d-char regex", len(query or ""))
        return 0
    return self.__search_handler.highlight_search(query, use_regex, match_case)

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/ansi_text_viewer.py
def next_match(self) -> int:
    """Jump to the next match, wrapping around.

    Returns:
        The 1-based index of the now-active match.
    """
    return self.__search_handler.next_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/ansi_text_viewer.py
def prev_match(self) -> int:
    """Jump to the previous match, wrapping around.

    Returns:
        The 1-based index of the now-active match.
    """
    return self.__search_handler.prev_match()

Clear all search highlights and selection state.

Source code in ansi_text_viewer/ansi_text_viewer.py
def clear_search_highlight(self):
    """Clear all search highlights and selection state."""
    self.__search_handler.clear_search_highlight()

Show only lines matching query; empty query restores all.

Parameters:

Name Type Description Default
query str

Text or pattern lines must contain.

required
use_regex bool

Treat query as a regular expression.

False
match_case bool

Case-sensitive matching.

False
Source code in ansi_text_viewer/ansi_text_viewer.py
def filter_search(
    self, query: str, use_regex: bool = False, match_case: bool = False
):
    """Show only lines matching *query*; empty *query* restores all.

    Args:
        query: Text or pattern lines must contain.
        use_regex: Treat *query* as a regular expression.
        match_case: Case-sensitive matching.
    """
    if use_regex and len(query or "") > 500:
        logger.debug("filter_search: rejected %d-char regex", len(query or ""))
        query = ""
        use_regex = False
    self.__search_handler.apply_filter(query, use_regex, match_case)

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 leave it unchanged.

None

Examples:

>>> viewer.setSearchHighlightColor(QColor(255, 255, 0, 100))
Source code in ansi_text_viewer/ansi_text_viewer.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
            leave it unchanged.

    Examples:
        >>> viewer.setSearchHighlightColor(QColor(255, 255, 0, 100))
    """
    self.__search_handler.setSearchHighlightColor(color, active_color)

Return the (match, active) highlight colors.

Source code in ansi_text_viewer/ansi_text_viewer.py
def searchHighlightColors(self) -> tuple[QColor, QColor]:
    """Return the ``(match, active)`` highlight colors."""
    handler = self.__search_handler
    return (
        QColor(handler.search_highlight_color),
        QColor(handler.active_search_color),
    )

Limits and performance

Two caps bound steady-state memory: setMaximumBlocks() (default 10,000 lines, oldest trimmed) and setMaxLineLength() (default 10,000 chars per line, truncated with a marker). Measured on this machine: 10k plain lines add ~6MB over Qt's ~4MB baseline; a 2MB single line would spike ~70MB of per-character overhead, hence the truncation. Bulk appends freeze widget updates and refresh once, and bookmark scans are skipped entirely when no bookmarks exist — 2000 streaming lines land in ~0.5s. Dense URL anchors are the one known cost (~4KB/line); turn links off for firehose logs.

Cap kept lines for steady memory (default 10000, max 200000).

Parameters:

Name Type Description Default
n int

Maximum document blocks; clamped to 100..200000.

required
Source code in ansi_text_viewer/ansi_text_viewer.py
def setMaximumBlocks(self, n: int):
    """Cap kept lines for steady memory (default 10000, max 200000).

    Args:
        n: Maximum document blocks; clamped to 100..200000.
    """
    try:
        keep = int(n)
    except (TypeError, ValueError):
        keep = 10000
    clamped = max(100, min(keep, 200000))
    if clamped != keep:
        logger.debug("setMaximumBlocks: clamped %r to %d", n, clamped)
    self.__max_blocks = clamped
    self.setMaximumBlockCount(self.__max_blocks)

Return the current maximum block count.

Source code in ansi_text_viewer/ansi_text_viewer.py
def maximumBlocks(self) -> int:
    """Return the current maximum block count."""
    return self.__max_blocks

Cap single-line length.

Longer lines are truncated with a marker. This bounds the per-character overhead (a 2MB single line spikes ~70MB otherwise). 0 disables truncation.

Parameters:

Name Type Description Default
n int

Maximum characters per line, clamped to 0..1000000.

required
Source code in ansi_text_viewer/ansi_text_viewer.py
def setMaxLineLength(self, n: int):
    """Cap single-line length.

    Longer lines are truncated with a marker. This bounds the
    per-character overhead (a 2MB single line spikes ~70MB otherwise).
    ``0`` disables truncation.

    Args:
        n: Maximum characters per line, clamped to 0..1000000.
    """
    try:
        keep = int(n)
    except (TypeError, ValueError):
        keep = 0
    clamped = max(0, min(keep, 1000000))
    if keep and clamped != keep:
        logger.debug("setMaxLineLength: clamped %r to %d", n, clamped)
    self.__max_line_length = clamped

Return the per-line character cap (0 means off).

Source code in ansi_text_viewer/ansi_text_viewer.py
def maxLineLength(self) -> int:
    """Return the per-line character cap (0 means off)."""
    return self.__max_line_length

Fonts & zoom

Fonts default to the system fixed font at 10pt and ignore later application-font changes (frozen for layout stability). setFontSize() doubles as the zoom-reset size, setFontFamily() keeps the size, and useSystemFont() takes a one-shot snapshot of QApplication.font().

Use a monospace font for the log view.

Parameters:

Name Type Description Default
family str | None

Font family name, or None for the system fixed font.

None
pointSize int

Point size, also stored as the zoom-reset size.

10

Examples:

>>> viewer.setMonospaceFont("Consolas", 11)
Source code in ansi_text_viewer/ansi_text_viewer.py
def setMonospaceFont(self, family: str | None = None, pointSize: int = 10):
    """Use a monospace font for the log view.

    Args:
        family: Font family name, or None for the system fixed font.
        pointSize: Point size, also stored as the zoom-reset size.

    Examples:
        >>> viewer.setMonospaceFont("Consolas", 11)
    """
    self.__base_font_size = pointSize
    if family:
        font = QFont(family, pointSize)
    else:
        try:
            font = QFontDatabase.systemFont(_FIXED_FONT)
            font.setPointSize(pointSize)
        except Exception:
            font = QFont("Consolas", pointSize)
    font.setStyleHint(QFont.StyleHint.Monospace)
    self.setFont(font)

Set the point size, keeping it as the zoom-reset size.

Parameters:

Name Type Description Default
pointSize int

Clamped to 6..72; invalid input keeps the current size.

required

Examples:

>>> viewer.setFontSize(12)
Source code in ansi_text_viewer/ansi_text_viewer.py
def setFontSize(self, pointSize: int) -> None:
    """Set the point size, keeping it as the zoom-reset size.

    Args:
        pointSize: Clamped to 6..72; invalid input keeps the current size.

    Examples:
        >>> viewer.setFontSize(12)
    """
    try:
        size = int(pointSize)
    except (TypeError, ValueError):
        return
    clamped = max(6, min(size, 72))
    if clamped != size:
        logger.debug("setFontSize: clamped %r to %d", pointSize, clamped)
    self.__base_font_size = clamped
    font = self.font()
    font.setPointSize(clamped)
    self.setFont(font)

Set the font family, leaving the current size alone.

Parameters:

Name Type Description Default
family str

Installed family name; unknown names are ignored by Qt.

required

Examples:

>>> viewer.setFontFamily("Consolas")
Source code in ansi_text_viewer/ansi_text_viewer.py
def setFontFamily(self, family: str) -> None:
    """Set the font family, leaving the current size alone.

    Args:
        family: Installed family name; unknown names are ignored by Qt.

    Examples:
        >>> viewer.setFontFamily("Consolas")
    """
    if not family:
        return
    font = self.font()
    font.setFamily(str(family))
    self.setFont(font)

Adopt the global QApplication font as a one-shot snapshot.

Later application-font changes are not tracked; call again or use :meth:setMonospaceFont to switch back.

Examples:

>>> viewer.useSystemFont()
Source code in ansi_text_viewer/ansi_text_viewer.py
def useSystemFont(self) -> None:
    """Adopt the global ``QApplication`` font as a one-shot snapshot.

    Later application-font changes are not tracked; call again or use
    :meth:`setMonospaceFont` to switch back.

    Examples:
        >>> viewer.useSystemFont()
    """
    app = QApplication.instance()
    if not isinstance(app, QApplication):
        return
    font = QFont(app.font())
    if font.pointSize() > 0:
        self.__base_font_size = max(6, min(font.pointSize(), 72))
        font.setPointSize(self.__base_font_size)
    self.setFont(font)

Restore the font size from before zooming.

Source code in ansi_text_viewer/ansi_text_viewer.py
def resetZoom(self):
    """Restore the font size from before zooming."""
    font = self.font()
    font.setPointSize(self.__base_font_size)
    self.setFont(font)

Zoom on Ctrl+Wheel, otherwise scroll normally.

Source code in ansi_text_viewer/ansi_text_viewer.py
def wheelEvent(self, event):
    """Zoom on Ctrl+Wheel, otherwise scroll normally."""
    if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
        delta = event.angleDelta().y()
        if delta > 0:
            self.zoomIn(1)
        elif delta < 0:
            self.zoomOut(1)
        event.accept()
        return
    super().wheelEvent(event)

Layout, scroll and gutter

Word wrap is off by default (logs scroll sideways); auto-scroll pins the bottom on new output; the gutter shows line numbers with bookmark ticks. The lineNumberArea* methods and resizeEvent are Qt plumbing most apps never call directly — listed here for completeness.

Toggle word wrap (off by default for log output).

Parameters:

Name Type Description Default
enabled bool

True wraps at the widget edge, False scrolls sideways.

required
Source code in ansi_text_viewer/ansi_text_viewer.py
def setWordWrapEnabled(self, enabled: bool):
    """Toggle word wrap (off by default for log output).

    Args:
        enabled: True wraps at the widget edge, False scrolls sideways.
    """
    self.__word_wrap_enabled = _to_bool(enabled)
    self.setLineWrapMode(
        QPlainTextEdit.LineWrapMode.WidgetWidth
        if self.__word_wrap_enabled
        else QPlainTextEdit.LineWrapMode.NoWrap
    )

Return True when word wrap is on.

Source code in ansi_text_viewer/ansi_text_viewer.py
def isWordWrapEnabled(self) -> bool:
    """Return True when word wrap is on."""
    return self.__word_wrap_enabled

Scroll to the bottom as new output arrives.

Source code in ansi_text_viewer/ansi_text_viewer.py
def setAutoScroll(self, enabled: bool):
    """Scroll to the bottom as new output arrives."""
    self.__auto_scroll = _to_bool(enabled)

Return True when auto-scroll is on.

Source code in ansi_text_viewer/ansi_text_viewer.py
def isAutoScroll(self) -> bool:
    """Return True when auto-scroll is on."""
    return self.__auto_scroll

Show or hide the gutter line numbers.

Source code in ansi_text_viewer/ansi_text_viewer.py
def setLineNumbersVisible(self, visible: bool):
    """Show or hide the gutter line numbers."""
    self.__line_numbers_visible = _to_bool(visible)
    self.lineNumberArea.setVisible(self.__line_numbers_visible)
    self.updateLineNumberAreaWidth(0)

Return True when gutter line numbers are shown.

Source code in ansi_text_viewer/ansi_text_viewer.py
def isLineNumbersVisible(self) -> bool:
    """Return True when gutter line numbers are shown."""
    return self.__line_numbers_visible

Return the gutter width in pixels (0 when hidden).

Source code in ansi_text_viewer/ansi_text_viewer.py
def lineNumberAreaWidth(self):
    """Return the gutter width in pixels (0 when hidden)."""
    if not self.__line_numbers_visible:
        return 0
    digits = 1
    max_val = max(1, self.blockCount())
    while max_val >= 10:
        max_val //= 10
        digits += 1
    space = 15 + self.fontMetrics().horizontalAdvance("9") * digits
    return space

Sync viewport margins with the gutter width.

Source code in ansi_text_viewer/ansi_text_viewer.py
def updateLineNumberAreaWidth(self, _):
    """Sync viewport margins with the gutter width."""
    if self.__bulk_depth:
        return
    self.setViewportMargins(self.lineNumberAreaWidth(), 0, 0, 0)

Repaint or scroll the gutter after document updates.

Source code in ansi_text_viewer/ansi_text_viewer.py
def updateLineNumberArea(self, rect: QRect, dy: int):
    """Repaint or scroll the gutter after document updates."""
    if dy:
        self.lineNumberArea.scroll(0, dy)
    else:
        self.lineNumberArea.update(
            0, rect.y(), self.lineNumberArea.width(), rect.height()
        )
    if rect.contains(self.viewport().rect()):
        self.updateLineNumberAreaWidth(0)

Keep the gutter geometry in sync on resize.

Source code in ansi_text_viewer/ansi_text_viewer.py
def resizeEvent(self, event):
    """Keep the gutter geometry in sync on resize."""
    super().resizeEvent(event)
    cr = self.contentsRect()
    self.lineNumberArea.setGeometry(
        QRect(cr.left(), cr.top(), self.lineNumberAreaWidth(), cr.height())
    )

Paint line numbers and bookmark markers in the gutter.

Source code in ansi_text_viewer/ansi_text_viewer.py
def lineNumberAreaPaintEvent(self, event):
    """Paint line numbers and bookmark markers in the gutter."""
    if not self.__line_numbers_visible:
        return

    painter = QPainter(self.lineNumberArea)
    painter.setFont(self.font())
    painter.fillRect(event.rect(), self.__gutter_bg)

    block = self.firstVisibleBlock()
    blockNumber = block.blockNumber()
    top = int(
        self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
    )
    bottom = top + int(self.blockBoundingRect(block).height())

    while block.isValid() and top <= event.rect().bottom():
        if block.isVisible() and bottom >= event.rect().top():
            number = str(blockNumber + 1)
            painter.setPen(self.__gutter_fg)
            painter.drawText(
                0,
                top,
                self.lineNumberArea.width() - 4,
                self.fontMetrics().height(),
                Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
                number,
            )
            if isinstance(block.userData(), _BookmarkData):
                painter.fillRect(
                    0, top, 6, self.fontMetrics().height(), QColor(60, 120, 255)
                )

        block = block.next()
        top = bottom
        bottom = top + int(self.blockBoundingRect(block).height())
        blockNumber += 1

Timestamps and highlight toggles

setTimestampEnabled() prefixes new lines with the current time. setLogLevelHighlighting() colors [ERROR]/[WARN]/[INFO]/[DEBUG] markers; setSyntaxHighlighting() covers tracebacks, JSON keys, CMake and justfiles (see Highlighting). Custom languages plug in through add_highlight_rule() without touching the viewer.

Prefix each appended line with the current time.

Parameters:

Name Type Description Default
enabled bool

Whether to timestamp new lines.

required
fmt str

strftime format for the prefix.

'[%H:%M:%S]'
Source code in ansi_text_viewer/ansi_text_viewer.py
def setTimestampEnabled(self, enabled: bool, fmt: str = "[%H:%M:%S]"):
    """Prefix each appended line with the current time.

    Args:
        enabled: Whether to timestamp new lines.
        fmt: `strftime` format for the prefix.
    """
    self.__timestamp_enabled = _to_bool(enabled)
    if fmt:
        self.__timestamp_format = fmt

Return True when timestamping is on.

Source code in ansi_text_viewer/ansi_text_viewer.py
def isTimestampEnabled(self) -> bool:
    """Return True when timestamping is on."""
    return self.__timestamp_enabled

Colors are injectable: setLogLevelColors() replaces the level map used for future highlights (read back with logLevelColors()).

Color [ERROR]/[WARN]/[INFO]/[DEBUG] markers.

Parameters:

Name Type Description Default
enabled bool

Whether to highlight newly appended blocks.

required
Source code in ansi_text_viewer/ansi_text_viewer.py
def setLogLevelHighlighting(self, enabled: bool):
    """Color ``[ERROR]``/``[WARN]``/``[INFO]``/``[DEBUG]`` markers.

    Args:
        enabled: Whether to highlight newly appended blocks.
    """
    self.__log_level_enabled = _to_bool(enabled)

Return True when log-level highlighting is on.

Source code in ansi_text_viewer/ansi_text_viewer.py
def isLogLevelHighlighting(self) -> bool:
    """Return True when log-level highlighting is on."""
    return self.__log_level_enabled

Override level-name to color mapping for future highlights.

Parameters:

Name Type Description Default
colors dict

e.g. {"ERROR": QColor(255, 0, 0)}; unknown levels fall back to a dark red.

required

Examples:

>>> viewer.setLogLevelColors({"ERROR": QColor(200, 0, 0)})
Source code in ansi_text_viewer/ansi_text_viewer.py
def setLogLevelColors(self, colors: dict) -> None:
    """Override level-name to color mapping for future highlights.

    Args:
        colors: e.g. ``{"ERROR": QColor(255, 0, 0)}``; unknown levels
            fall back to a dark red.

    Examples:
        >>> viewer.setLogLevelColors({"ERROR": QColor(200, 0, 0)})
    """
    self.__log_rule.colors = dict(colors)

Return the current level-name to color mapping.

Source code in ansi_text_viewer/ansi_text_viewer.py
def logLevelColors(self) -> dict:
    """Return the current level-name to color mapping."""
    return dict(self.__log_rule.colors)

Highlight tracebacks, JSON keys, CMake and justfile syntax.

Parameters:

Name Type Description Default
enabled bool

Whether to highlight newly appended blocks.

required
Source code in ansi_text_viewer/ansi_text_viewer.py
def setSyntaxHighlighting(self, enabled: bool):
    """Highlight tracebacks, JSON keys, CMake and justfile syntax.

    Args:
        enabled: Whether to highlight newly appended blocks.
    """
    self.__syntax_highlight_enabled = _to_bool(enabled)

Return True when syntax highlighting is on.

Source code in ansi_text_viewer/ansi_text_viewer.py
def isSyntaxHighlighting(self) -> bool:
    """Return True when syntax highlighting is on."""
    return self.__syntax_highlight_enabled

Register a custom rule, applied after the built-in rules.

Parameters:

Name Type Description Default
rule HighlightRule

Any object with a spans(text) method.

required

Examples:

>>> from ansi_text_viewer.highlight import HighlightRule
>>> viewer.add_highlight_rule(MyRule())
Source code in ansi_text_viewer/ansi_text_viewer.py
def add_highlight_rule(self, rule: HighlightRule) -> None:
    """Register a custom rule, applied after the built-in rules.

    Args:
        rule: Any object with a ``spans(text)`` method.

    Examples:
        >>> from ansi_text_viewer.highlight import HighlightRule
        >>> viewer.add_highlight_rule(MyRule())
    """
    self.__custom_rules.append(rule)

Unregister a custom rule added with :meth:add_highlight_rule.

Source code in ansi_text_viewer/ansi_text_viewer.py
def remove_highlight_rule(self, rule: HighlightRule) -> None:
    """Unregister a custom rule added with :meth:`add_highlight_rule`."""
    if rule in self.__custom_rules:
        self.__custom_rules.remove(rule)

Return the registered custom rules (built-ins excluded).

Source code in ansi_text_viewer/ansi_text_viewer.py
def highlight_rules(self) -> list[HighlightRule]:
    """Return the registered custom rules (built-ins excluded)."""
    return list(self.__custom_rules)

Current line

Highlights the cursor line, merged with — never replacing — search and bookmark highlights. setExtraSelections() is the interception point the search engine writes through; searchExtraSelections() reads back just the search part.

Highlight the line under the text cursor.

Merged with search highlights, never replacing them.

Parameters:

Name Type Description Default
enabled bool

Whether to highlight the current line.

required
color QColor | None

Background color, or None to keep the default.

None
Source code in ansi_text_viewer/ansi_text_viewer.py
def setCurrentLineHighlightEnabled(
    self, enabled: bool, color: QColor | None = None
):
    """Highlight the line under the text cursor.

    Merged with search highlights, never replacing them.

    Args:
        enabled: Whether to highlight the current line.
        color: Background color, or None to keep the default.
    """
    self.__current_line_enabled = _to_bool(enabled)
    if color is not None:
        self.__current_line_color = color
    self.__refresh_extra_selections()

Return True when current-line highlight is on.

Source code in ansi_text_viewer/ansi_text_viewer.py
def isCurrentLineHighlightEnabled(self) -> bool:
    """Return True when current-line highlight is on."""
    return self.__current_line_enabled

Return the current-line highlight color.

Source code in ansi_text_viewer/ansi_text_viewer.py
def currentLineColor(self) -> QColor:
    """Return the current-line highlight color."""
    return QColor(self.__current_line_color)

Store search selections, merged with line/bookmark highlights.

Source code in ansi_text_viewer/ansi_text_viewer.py
def setExtraSelections(self, selections):
    """Store search selections, merged with line/bookmark highlights."""
    self.__search_extra = list(selections)
    self.__refresh_extra_selections()

Return the stored search selections (without merged extras).

Source code in ansi_text_viewer/ansi_text_viewer.py
def searchExtraSelections(self):
    """Return the stored search selections (without merged extras)."""
    return list(self.__search_extra)

https:// URLs and path/to/file.py:line references become clickable anchors, opened with Ctrl + click (plain clicks just move the cursor — single-click-open would let a hostile log line phish you). mousePressEvent enforces the modifier, mouseMoveEvent shows the pointing hand, and contextMenuEvent builds the right-click menu. Disable entirely with setLinksEnabled(False) for untrusted logs.

Toggle clickable URLs and file paths (opened with Ctrl+Click).

Parameters:

Name Type Description Default
enabled bool

False also hardens the viewer against hostile logs.

required
Source code in ansi_text_viewer/ansi_text_viewer.py
def setLinksEnabled(self, enabled: bool):
    """Toggle clickable URLs and file paths (opened with Ctrl+Click).

    Args:
        enabled: False also hardens the viewer against hostile logs.
    """
    self.__links_enabled = _to_bool(enabled)

Return True when clickable links are on.

Source code in ansi_text_viewer/ansi_text_viewer.py
def isLinksEnabled(self) -> bool:
    """Return True when clickable links are on."""
    return self.__links_enabled

Open links only on Ctrl+Click; plain clicks just move the cursor.

Source code in ansi_text_viewer/ansi_text_viewer.py
def mousePressEvent(self, event):
    """Open links only on Ctrl+Click; plain clicks just move the cursor."""
    if self.__links_enabled and event.button() == Qt.MouseButton.LeftButton:
        if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
            anchor = self.anchorAt(event.pos())
            if anchor:
                if "://" in anchor or anchor.startswith("mailto:"):
                    url = QUrl(anchor)
                else:
                    url = QUrl.fromLocalFile(anchor)
                opened = QDesktopServices.openUrl(url)
                logger.debug("open link %r -> %s", anchor, opened)
                event.accept()
                return
    super().mousePressEvent(event)

Show a pointing hand while hovering a link.

Source code in ansi_text_viewer/ansi_text_viewer.py
def mouseMoveEvent(self, event):
    """Show a pointing hand while hovering a link."""
    if self.__links_enabled:
        anchor = self.anchorAt(event.pos())
        if anchor:
            self.viewport().setCursor(Qt.CursorShape.PointingHandCursor)
        else:
            self.viewport().unsetCursor()
    super().mouseMoveEvent(event)

Build the right-click menu (copy, bookmarks, view, save).

Source code in ansi_text_viewer/ansi_text_viewer.py
def contextMenuEvent(self, event):
    """Build the right-click menu (copy, bookmarks, view, save)."""
    menu = self.createStandardContextMenu()
    menu.addSeparator()
    copy_plain = menu.addAction("Copy Plain Text")
    copy_plain.triggered.connect(self.copySelectedPlainText)
    copy_ansi = menu.addAction("Copy With ANSI Codes")
    copy_ansi.triggered.connect(self.copySelectedWithAnsi)
    bm = menu.addAction("Toggle Bookmark")
    bm.triggered.connect(lambda: self.toggleBookmark())
    wrap = menu.addAction("Toggle Word Wrap")
    wrap.setCheckable(True)
    wrap.setChecked(self.__word_wrap_enabled)
    wrap.triggered.connect(
        lambda: self.setWordWrapEnabled(not self.__word_wrap_enabled)
    )
    lines = menu.addAction("Toggle Line Numbers")
    lines.setCheckable(True)
    lines.setChecked(self.__line_numbers_visible)
    lines.triggered.connect(
        lambda: self.setLineNumbersVisible(not self.__line_numbers_visible)
    )
    clear_act = menu.addAction("Clear")
    clear_act.triggered.connect(self.clear)
    save_act = menu.addAction("Save to File...")
    save_act.triggered.connect(self._save_via_dialog)
    menu.exec(event.globalPos())

Copy, export, sessions

  • copySelectedPlainText() and copySelectedWithAnsi() (SGR codes reconstructed from the actual formats).
  • exportToFile() writes .txt, .html, or .md from the suffix.
  • saveSession() / loadSession() persist text, bookmarks, and settings as JSON, with size and shape validation (10MB file / 5MB text caps).
  • selectionStats() returns chars/words/lines for status bars.
viewer.saveSession("run.json")
viewer.loadSession("run.json")

Return the selected text with paragraph separators as newlines.

Source code in ansi_text_viewer/ansi_text_viewer.py
def selectedPlainText(self) -> str:
    """Return the selected text with paragraph separators as newlines."""
    cursor = self.textCursor()
    if not cursor.hasSelection():
        return ""
    return cursor.selectedText().replace("\u2029", "\n")

Copy the selection as plain text.

Returns:

Type Description
str

The copied text, or "" when nothing is selected.

Source code in ansi_text_viewer/ansi_text_viewer.py
def copySelectedPlainText(self) -> str:
    """Copy the selection as plain text.

    Returns:
        The copied text, or "" when nothing is selected.
    """
    text = self.selectedPlainText()
    if text:
        QApplication.clipboard().setText(text)
    return text

Reconstruct ANSI SGR from fragment formats (true ANSI copy).

Source code in ansi_text_viewer/ansi_text_viewer.py
def selectedTextWithAnsi(self) -> str:
    """Reconstruct ANSI SGR from fragment formats (true ANSI copy)."""
    cursor = self.textCursor()
    if not cursor.hasSelection():
        return ""
    sel_start, sel_end = cursor.selectionStart(), cursor.selectionEnd()
    doc = self.document()
    parts: list[str] = []
    block = doc.findBlock(sel_start)
    while block.isValid() and block.position() < sel_end:
        it = block.begin()
        while not it.atEnd():
            frag = it.fragment()
            f_start, f_end = frag.position(), frag.position() + frag.length()
            s, e = max(f_start, sel_start), min(f_end, sel_end)
            if s < e:
                sub = frag.text()[s - f_start : e - f_start]
                params = self._format_to_sgr_params(frag.charFormat())
                if params:
                    parts.append(f"\x1b[{';'.join(map(str, params))}m{sub}\x1b[0m")
                else:
                    parts.append(sub)
            it += 1
        block = block.next()
        if block.isValid() and block.position() < sel_end:
            parts.append("\n")
    return "".join(parts)

Copy the selection with ANSI color codes reconstructed.

Returns:

Type Description
str

The copied text with SGR sequences, or "" when empty.

Examples:

>>> viewer.setAnsiText("\x1b[31mred\x1b[0m\n")
... # select all, then:
>>> "31m" in viewer.selectedTextWithAnsi()
True
Source code in ansi_text_viewer/ansi_text_viewer.py
def copySelectedWithAnsi(self) -> str:
    r"""Copy the selection with ANSI color codes reconstructed.

    Returns:
        The copied text with SGR sequences, or "" when empty.

    Examples:
        >>> viewer.setAnsiText("\x1b[31mred\x1b[0m\n")
        ... # select all, then:
        >>> "31m" in viewer.selectedTextWithAnsi()
        True
    """
    text = self.selectedTextWithAnsi()
    if text:
        QApplication.clipboard().setText(text)
    return text

Save content to .txt, .html or .md based on suffix.

Parameters:

Name Type Description Default
path str

Destination file path.

required

Returns:

Type Description
str

The path that was written.

Source code in ansi_text_viewer/ansi_text_viewer.py
def exportToFile(self, path: str) -> str:
    """Save content to ``.txt``, ``.html`` or ``.md`` based on suffix.

    Args:
        path: Destination file path.

    Returns:
        The path that was written.
    """
    low = path.lower()
    if low.endswith(".html"):
        content = self.document().toHtml()
    elif low.endswith(".md"):
        content = "```text\n" + self.toPlainText() + "\n```\n"
    else:
        content = self.toPlainText()
    with open(path, "w", encoding="utf-8") as f:
        f.write(content)
    return path

Count the current selection.

Returns:

Type Description
dict

Dict with chars, words and lines (zeros when empty).

Source code in ansi_text_viewer/ansi_text_viewer.py
def selectionStats(self) -> dict:
    """Count the current selection.

    Returns:
        Dict with ``chars``, ``words`` and ``lines`` (zeros when empty).
    """
    text = self.selectedPlainText()
    if not text:
        return {"chars": 0, "words": 0, "lines": 0}
    return {
        "chars": len(text),
        "words": len(text.split()),
        "lines": text.count("\n") + 1,
    }

Save text, bookmarks and settings as JSON.

Parameters:

Name Type Description Default
path str

Destination .json file.

required

Returns:

Type Description
str

The path that was written.

Source code in ansi_text_viewer/ansi_text_viewer.py
def saveSession(self, path: str) -> str:
    """Save text, bookmarks and settings as JSON.

    Args:
        path: Destination ``.json`` file.

    Returns:
        The path that was written.
    """
    import json

    fg_default, bg_default = self.defaultColors()
    data = {
        "version": 2,
        "text": self.toPlainText(),
        "bookmarks": self.bookmarkedLines(),
        "bookmarkPreviews": self.bookmarkedPreviews(),
        "settings": {
            "wordWrap": self.__word_wrap_enabled,
            "timestamp": self.__timestamp_enabled,
            "logLevel": self.__log_level_enabled,
            "syntax": self.__syntax_highlight_enabled,
            "theme": self.__theme_requested,
            "maxBlocks": self.__max_blocks,
            "maxLineLength": self.__max_line_length,
            "currentLine": self.__current_line_enabled,
            "clearOnStart": self.__clear_on_start,
            "colorsFollowTheme": self.__colors_follow_theme,
            "defaultFg": [
                fg_default.red(),
                fg_default.green(),
                fg_default.blue(),
            ],
            "defaultBg": [
                bg_default.red(),
                bg_default.green(),
                bg_default.blue(),
            ],
        },
    }
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)
    return path

Restore a session written by :meth:saveSession.

Files over 10MB or with invalid content are rejected.

Parameters:

Name Type Description Default
path str

Source .json file.

required

Returns:

Type Description
str

The path that was read.

Raises:

Type Description
ValueError

When the file is too large or invalid.

Source code in ansi_text_viewer/ansi_text_viewer.py
def loadSession(self, path: str) -> str:
    """Restore a session written by :meth:`saveSession`.

    Files over 10MB or with invalid content are rejected.

    Args:
        path: Source ``.json`` file.

    Returns:
        The path that was read.

    Raises:
        ValueError: When the file is too large or invalid.
    """
    import json
    import os

    if os.path.getsize(path) > 10 * 1024 * 1024:
        raise ValueError("session file too large (>10MB)")
    with open(path, encoding="utf-8") as f:
        data = json.load(f)
    if not isinstance(data, dict):
        raise ValueError("invalid session file")
    text = data.get("text", "")
    if not isinstance(text, str) or len(text) > 5 * 1024 * 1024:
        raise ValueError("invalid session text")
    settings = data.get("settings", {})
    if not isinstance(settings, dict):
        settings = {}
    self.setWordWrapEnabled(settings.get("wordWrap", False))
    self.setTimestampEnabled(settings.get("timestamp", False))
    self.setLogLevelHighlighting(settings.get("logLevel", False))
    self.setSyntaxHighlighting(settings.get("syntax", False))
    self.setTheme(settings.get("theme", "light"))
    self.setMaximumBlocks(settings.get("maxBlocks", 10000))
    if "maxLineLength" in settings:
        self.setMaxLineLength(settings.get("maxLineLength", 10000))
    self.setCurrentLineHighlightEnabled(settings.get("currentLine", False))
    self.setClearOnStart(settings.get("clearOnStart", False))
    self.setColorsFollowTheme(settings.get("colorsFollowTheme", True))
    if not self.colorsFollowTheme():
        fg = settings.get("defaultFg")
        bg = settings.get("defaultBg")
        if (
            isinstance(fg, list)
            and isinstance(bg, list)
            and len(fg) == 3
            and len(bg) == 3
            and all(isinstance(c, int) for c in fg + bg)
        ):
            self.setDefaultColors(QColor(*fg), QColor(*bg))
    self.setAnsiText(text)
    self.clearBookmarks()
    bookmarks = data.get("bookmarks", [])
    if isinstance(bookmarks, list):
        for b in bookmarks[:10000]:
            try:
                self.addBookmark(int(b))
            except (TypeError, ValueError):
                continue
    return path

Bookmarks

Bookmarks live on the text block itself, so trimming, filtering, or clearing can never leave stale line numbers behind. Toggle via toggleBookmark(), by clicking the gutter (toggleBookmarkAtY() refuses invisible blocks), or from the context menu; gotoNextBookmark() / gotoPrevBookmark() wrap around and skip filtered-out lines. bookmarkedLines(), bookmarkedPreviews(), and the bookmarksChanged signal feed list UIs like the demo's panel.

Bookmark or unbookmark a line.

Bookmarks live on the block itself, so trimming or clearing the document can never leave stale numbers behind.

Parameters:

Name Type Description Default
blockNumber int | None

0-based block, or None for the cursor line.

None

Returns:

Type Description
bool

True when a bookmark was added, False when removed.

Source code in ansi_text_viewer/ansi_text_viewer.py
def toggleBookmark(self, blockNumber: int | None = None) -> bool:
    """Bookmark or unbookmark a line.

    Bookmarks live on the block itself, so trimming or clearing the
    document can never leave stale numbers behind.

    Args:
        blockNumber: 0-based block, or None for the cursor line.

    Returns:
        True when a bookmark was added, False when removed.
    """
    if blockNumber is None:
        blockNumber = self.textCursor().blockNumber()
    block = self.document().findBlockByNumber(int(blockNumber))
    if not block.isValid():
        logger.debug("toggleBookmark: no block %r", blockNumber)
        return False
    if isinstance(block.userData(), _BookmarkData):
        self._drop_block_data(block)
        self.__bookmark_count -= 1
        added = False
    else:
        block.setUserData(_BookmarkData())
        self.__bookmark_count += 1
        added = True
    self.__refresh_extra_selections()
    self.lineNumberArea.update()
    self._emit_bookmarks()
    return added

Toggle the bookmark on the visible block at gutter height y.

Parameters:

Name Type Description Default
y int

Vertical position inside the gutter widget.

required

Returns:

Type Description
int

The toggled block number, or -1 when none is there.

Source code in ansi_text_viewer/ansi_text_viewer.py
def toggleBookmarkAtY(self, y: int) -> int:
    """Toggle the bookmark on the visible block at gutter height *y*.

    Args:
        y: Vertical position inside the gutter widget.

    Returns:
        The toggled block number, or -1 when none is there.
    """
    block = self.firstVisibleBlock()
    top = int(
        self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
    )
    while block.isValid():
        h = int(self.blockBoundingRect(block).height())
        if top <= y < top + h:
            if not block.isVisible():
                return -1
            self.toggleBookmark(block.blockNumber())
            return block.blockNumber()
        top += h
        block = block.next()
    logger.debug("toggleBookmarkAtY: no visible block at y=%d", y)
    return -1

Bookmark a 0-based block number (no-op when invalid).

Source code in ansi_text_viewer/ansi_text_viewer.py
def addBookmark(self, blockNumber: int):
    """Bookmark a 0-based block number (no-op when invalid)."""
    block = self.document().findBlockByNumber(int(blockNumber))
    if block.isValid() and not isinstance(block.userData(), _BookmarkData):
        block.setUserData(_BookmarkData())
        self.__bookmark_count += 1
        self.__refresh_extra_selections()
        self.lineNumberArea.update()
        self._emit_bookmarks()

Remove the bookmark on a 0-based block number.

Source code in ansi_text_viewer/ansi_text_viewer.py
def removeBookmark(self, blockNumber: int):
    """Remove the bookmark on a 0-based block number."""
    block = self.document().findBlockByNumber(int(blockNumber))
    if block.isValid() and isinstance(block.userData(), _BookmarkData):
        self._drop_block_data(block)
        self.__bookmark_count -= 1
        self.__refresh_extra_selections()
        self.lineNumberArea.update()
        self._emit_bookmarks()

Return True when the block carries a bookmark.

Source code in ansi_text_viewer/ansi_text_viewer.py
def isBookmarked(self, blockNumber: int) -> bool:
    """Return True when the block carries a bookmark."""
    block = self.document().findBlockByNumber(int(blockNumber))
    return block.isValid() and isinstance(block.userData(), _BookmarkData)

Return sorted 0-based numbers of bookmarked blocks.

Source code in ansi_text_viewer/ansi_text_viewer.py
def bookmarkedLines(self) -> list[int]:
    """Return sorted 0-based numbers of bookmarked blocks."""
    out = []
    block = self.document().firstBlock()
    while block.isValid():
        if isinstance(block.userData(), _BookmarkData):
            out.append(block.blockNumber())
        block = block.next()
    return out

Return (block, first-80-chars) pairs for the bookmark list UI.

Source code in ansi_text_viewer/ansi_text_viewer.py
def bookmarkedPreviews(self) -> list[tuple[int, str]]:
    """Return ``(block, first-80-chars)`` pairs for the bookmark list UI."""
    out = []
    block = self.document().firstBlock()
    while block.isValid():
        if isinstance(block.userData(), _BookmarkData):
            out.append((block.blockNumber(), block.text()[:80]))
        block = block.next()
    return out

Remove every bookmark and notify listeners.

Source code in ansi_text_viewer/ansi_text_viewer.py
def clearBookmarks(self):
    """Remove every bookmark and notify listeners."""
    block = self.document().firstBlock()
    changed = False
    while block.isValid():
        if isinstance(block.userData(), _BookmarkData):
            self._drop_block_data(block)
            changed = True
        block = block.next()
    self.__bookmark_count = 0
    self.__refresh_extra_selections()
    self.lineNumberArea.update()
    if changed:
        self._emit_bookmarks()

Jump to the next bookmark, wrapping around.

Filtered-out (invisible) bookmarks are skipped.

Returns:

Type Description
int

The target block number, or -1 when none is reachable.

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

    Filtered-out (invisible) bookmarks are skipped.

    Returns:
        The target block number, or -1 when none is reachable.
    """
    return self._goto_bookmark(1)

Jump to the previous bookmark, wrapping around.

Filtered-out (invisible) bookmarks are skipped.

Returns:

Type Description
int

The target block number, or -1 when none is reachable.

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

    Filtered-out (invisible) bookmarks are skipped.

    Returns:
        The target block number, or -1 when none is reachable.
    """
    return self._goto_bookmark(-1)

Set the full-width background of bookmarked lines.

Parameters:

Name Type Description Default
color QColor

Background color (translucency recommended).

required

Examples:

>>> viewer.setBookmarkColor(QColor(140, 180, 255, 90))
Source code in ansi_text_viewer/ansi_text_viewer.py
def setBookmarkColor(self, color: QColor) -> None:
    """Set the full-width background of bookmarked lines.

    Args:
        color: Background color (translucency recommended).

    Examples:
        >>> viewer.setBookmarkColor(QColor(140, 180, 255, 90))
    """
    self.__bookmark_color = QColor(color)
    self.__refresh_extra_selections()

Return the bookmark highlight color.

Source code in ansi_text_viewer/ansi_text_viewer.py
def bookmarkColor(self) -> QColor:
    """Return the bookmark highlight color."""
    return QColor(self.__bookmark_color)

Theme & colors

setTheme("light"), "dark", or "auto" (follows the OS and live-updates on system changes). Unstyled text colors follow the theme — black on white in light mode, #d4d4d4 on #1e1e1e in dark — so plain logs always match the background instead of showing terminal-white pills. Explicit SGR colors are never touched.

viewer.setTheme("auto")
viewer.setDefaultColors(QColor(0, 0, 0), QColor(255, 255, 255))  # manual
viewer.setColorsFollowTheme(True)  # back to automatic

Apply the widget theme.

Parameters:

Name Type Description Default
mode str

"light", "dark", or "auto" (follows the OS and live-updates on system changes).

'light'

Examples:

>>> viewer.setTheme("dark")
Source code in ansi_text_viewer/ansi_text_viewer.py
def setTheme(self, mode: str = "light"):
    """Apply the widget theme.

    Args:
        mode: ``"light"``, ``"dark"``, or ``"auto"`` (follows the OS
            and live-updates on system changes).

    Examples:
        >>> viewer.setTheme("dark")
    """
    mode = (mode or "light").lower()
    if mode not in ("light", "dark", "auto"):
        mode = "light"
    self.__theme_requested = mode
    self.__theme = self.__resolve_theme(mode)
    self._apply_theme(self.__theme)

Return the resolved theme ("light" or "dark").

Source code in ansi_text_viewer/ansi_text_viewer.py
def theme(self) -> str:
    """Return the resolved theme (``"light"`` or ``"dark"``)."""
    return self.__theme

Return the requested mode, including "auto".

Source code in ansi_text_viewer/ansi_text_viewer.py
def themeRequested(self) -> str:
    """Return the requested mode, including ``"auto"``."""
    return self.__theme_requested

Fix the SGR-reset text colors instead of following the theme.

Parameters:

Name Type Description Default
fg QColor

Default foreground for unstyled text.

required
bg QColor

Default background for unstyled text.

required

Examples:

>>> viewer.setDefaultColors(QColor(0, 0, 0), QColor(255, 255, 255))
Source code in ansi_text_viewer/ansi_text_viewer.py
def setDefaultColors(self, fg: QColor, bg: QColor) -> None:
    """Fix the SGR-reset text colors instead of following the theme.

    Args:
        fg: Default foreground for unstyled text.
        bg: Default background for unstyled text.

    Examples:
        >>> viewer.setDefaultColors(QColor(0, 0, 0), QColor(255, 255, 255))
    """
    self.__ansi_escape_handler.setDefaultColors(QColor(fg), QColor(bg))
    self.__colors_follow_theme = False

Return the current (foreground, background) defaults.

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

Follow the theme for default text colors.

Parameters:

Name Type Description Default
enabled bool

True re-applies the current theme's pair at once.

required
Source code in ansi_text_viewer/ansi_text_viewer.py
def setColorsFollowTheme(self, enabled: bool) -> None:
    """Follow the theme for default text colors.

    Args:
        enabled: True re-applies the current theme's pair at once.
    """
    self.__colors_follow_theme = _to_bool(enabled)
    if self.__colors_follow_theme:
        self._apply_theme(self.__theme)

Return True when default colors track the theme.

Source code in ansi_text_viewer/ansi_text_viewer.py
def colorsFollowTheme(self) -> bool:
    """Return True when default colors track the theme."""
    return self.__colors_follow_theme

Override one of the eight ANSI base colors.

Parameters:

Name Type Description Default
idx int

Base color 0..7 (black, red, green, yellow, blue, magenta, cyan, white).

required
color QColor

Replacement color.

required
bright bool

Whether it applies to the bright (90-97) variant.

False

Examples:

>>> viewer.setAnsiPaletteColor(1, QColor(255, 0, 0))
Source code in ansi_text_viewer/ansi_text_viewer.py
def setAnsiPaletteColor(self, idx: int, color: QColor, bright: bool = False):
    """Override one of the eight ANSI base colors.

    Args:
        idx: Base color 0..7 (black, red, green, yellow, blue,
            magenta, cyan, white).
        color: Replacement color.
        bright: Whether it applies to the bright (90-97) variant.

    Examples:
        >>> viewer.setAnsiPaletteColor(1, QColor(255, 0, 0))
    """
    self.__ansi_escape_handler.setAnsi8Color(idx, color, bright)

Restore the built-in ANSI palette.

Source code in ansi_text_viewer/ansi_text_viewer.py
def resetAnsiPalette(self):
    """Restore the built-in ANSI palette."""
    self.__ansi_escape_handler.resetPalette()

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

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