How to Implement a Custom LogFormatter for TUI-Logger in Rust

You can implement a custom LogFormatter for tui-logger by creating a struct that implements the LogFormatter trait with min_width() and format() methods, then inject it into TuiLoggerWidget via the .formatter() or .opt_formatter() methods.

The gin66/tui-logger crate provides a terminal user interface widget for displaying logs in Rust applications. While it includes a default output style, you gain full control over log rendering—colors, indentation, timestamps, and wrapping—by supplying your own formatter that conforms to the LogFormatter trait defined in src/widget/logformatter.rs.

Understanding the LogFormatter Trait

The LogFormatter trait serves as the extension point for customizing log output appearance. According to the source code in src/widget/logformatter.rs, any custom formatter must implement two required methods:

  • min_width(&self) -> u16: Returns the minimum widget width your layout requires. The widget uses this to determine if it has enough space to render your format.
  • format(&self, width: usize, evt: &ExtLogRecord) -> Vec<Line<'_>>: Transforms a single log record into one or more ratatui::text::Line objects. You handle text wrapping, styling, and indentation within this method.

The trait requires Send + Sync bounds, allowing the widget to store your formatter as Option<Box<dyn LogFormatter>> in the TuiLoggerWidget struct (see src/widget/standard.rs).

Implementing a Custom Formatter

Minimal Custom Formatter Example

The following example creates a formatter that displays only the timestamp and message in bold, handling basic word wrapping manually:

use ratatui::text::{Line, Span};
use ratatui::style::Style;
use tui_logger::widget::logformatter::LogFormatter;
use tui_logger::ExtLogRecord;

/// Simple formatter that prints only the message in bold.
pub struct BoldMessageFormatter {
    style: Style,
}

impl BoldMessageFormatter {
    pub fn new(style: Style) -> Self {
        Self { style }
    }
}

impl LogFormatter for BoldMessageFormatter {
    fn min_width(&self) -> u16 {
        // We need at least room for the timestamp + a space.
        12
    }

    fn format(&self, width: usize, evt: &ExtLogRecord) -> Vec<Line<'_>> {
        // Build a single-line output: "[timestamp] MESSAGE"
        let ts = evt.timestamp.format("%H:%M:%S");
        let content = format!("[{}] {}", ts, evt.msg());

        // Wrap manually if the line exceeds `width`.
        let mut lines = Vec::new();
        let mut cur = String::new();
        for word in content.split_whitespace() {
            if cur.len() + word.len() + 1 > width {
                lines.push(Line::from(Span::styled(cur.clone(), self.style)));
                cur.clear();
            }
            if !cur.is_empty() {
                cur.push(' ');
            }
            cur.push_str(word);
        }
        if !cur.is_empty() {
            lines.push(Line::from(Span::styled(cur, self.style)));
        }

        lines
    }
}

Advanced Formatter with Colored Levels and Wrapping

For a more sophisticated implementation, this formatter adds color-coded level prefixes and handles line continuation with proper indentation:

use ratatui::{
    style::{Color, Style},
    text::{Line, Span},
};
use tui_logger::widget::logformatter::LogFormatter;
use tui_logger::ExtLogRecord;

pub struct ColoredLevelFormatter {
    msg_style: Style,
    level_styles: [(log::Level, Style); 5],
}

impl ColoredLevelFormatter {
    pub fn new() -> Self {
        Self {
            msg_style: Style::default(),
            level_styles: [
                (log::Level::Error, Style::default().fg(Color::Red)),
                (log::Level::Warn,  Style::default().fg(Color::Yellow)),
                (log::Level::Info,  Style::default().fg(Color::Green)),
                (log::Level::Debug, Style::default().fg(Color::Cyan)),
                (log::Level::Trace, Style::default().fg(Color::Magenta)),
            ],
        }
    }

    fn style_for(&self, lvl: log::Level) -> Style {
        self.level_styles
            .iter()
            .find(|(l, _)| *l == lvl)
            .map(|(_, s)| *s)
            .unwrap_or(self.msg_style)
    }
}

impl LogFormatter for ColoredLevelFormatter {
    fn min_width(&self) -> u16 {
        // "[LEVEL] " takes 9 bytes, plus a space.
        10
    }

    fn format(&self, width: usize, evt: &ExtLogRecord) -> Vec<Line<'_>> {
        let level_str = format!("[{:5}]", evt.level);
        let level_span = Span::styled(level_str, self.style_for(evt.level));

        let mut lines = Vec::new();
        let mut remaining = evt.msg();

        while !remaining.is_empty() {
            let avail = width.saturating_sub(9); // 9 = len("[LEVEL] ")
            let (take, rest) = if remaining.len() > avail {
                let split = remaining[..avail]
                    .rfind(' ')
                    .unwrap_or(avail);
                remaining.split_at(split)
            } else {
                (remaining, "")
            };

            let mut spans = vec![level_span.clone()];
            spans.push(Span::styled(take.trim_start(), self.msg_style));
            lines.push(Line::from(spans));
            remaining = rest.trim_start();
        }

        lines
    }
}

Wiring the Formatter to TuiLoggerWidget

Once implemented, inject your formatter using either the builder pattern during widget construction or dynamically at runtime:

use tui_logger::{TuiLoggerWidget, TuiLoggerLevelOutput};
use ratatui::style::{Style, Modifier};

let bold_style = Style::default().add_modifier(Modifier::BOLD);
let my_formatter = BoldMessageFormatter::new(bold_style);

let widget = TuiLoggerWidget::default()
    .formatter(Box::new(my_formatter))
    .output_level(Some(TuiLoggerLevelOutput::Long))
    .block(Block::default().title("My logs"));

Alternatively, use .opt_formatter() to set or replace the formatter later:

widget = widget.opt_formatter(Some(Box::new(ColoredLevelFormatter::new())));

As implemented in src/widget/standard.rs, the widget stores the formatter in the field logformatter: Option<Box<dyn LogFormatter>>. During rendering, it extracts the formatter using self.logformatter.take(), falling back to the built-in LogStandardFormatter (defined in src/widget/standard_formatter.rs) if none is provided.

Summary

  • Implement LogFormatter: Define min_width() and format() in a struct to control line rendering, colors, and wrapping.
  • Handle ExtLogRecord: Access timestamp, level, and message content through the evt parameter in format().
  • Inject via builder: Use .formatter() or .opt_formatter() on TuiLoggerWidget to supply your custom implementation.
  • Reference source files: Study src/widget/standard_formatter.rs for the default wrapping logic and src/widget/logformatter.rs for the trait definition.

Frequently Asked Questions

What methods must I implement for the LogFormatter trait?

You must implement min_width(&self) -> u16, which specifies the minimum widget width your format requires, and format(&self, width: usize, evt: &ExtLogRecord) -> Vec<Line<'_>>, which converts a log record into styled lines for ratatui rendering.

Can I change the formatter after creating the TuiLoggerWidget?

Yes. The widget provides the .opt_formatter() method that accepts Option<Box<dyn LogFormatter>>, allowing you to swap formatters dynamically at runtime. The widget stores the formatter as an Option<Box<dyn LogFormatter>> and takes ownership during rendering.

How does the widget handle word wrapping in custom formatters?

The widget delegates all wrapping logic to your formatter's format() method. You receive the available width as a parameter and must split the ExtLogRecord message into appropriate Vec<Line<'_>> segments yourself. The reference implementation in src/widget/standard_formatter.rs demonstrates splitting on whitespace to avoid breaking words.

Does the custom formatter need to be Send and Sync?

Yes. The LogFormatter trait requires Send + Sync bounds because TuiLoggerWidget stores the formatter as Option<Box<dyn LogFormatter>> and may access it across asynchronous rendering contexts. Ensure your formatter struct implements these traits or uses types that do.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →