#![forbid(unsafe_code)]
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use ftui_core::geometry::Rect;
use ftui_layout::Constraint;
#[cfg(feature = "tracing")]
use ftui_render::buffer::Buffer;
use ftui_render::frame::Frame;
use ftui_render::grapheme_pool::GraphemePool;
#[cfg(feature = "tracing")]
use ftui_widgets::StatefulWidget;
use ftui_widgets::Widget;
use ftui_widgets::block::Block;
use ftui_widgets::paragraph::Paragraph;
use ftui_widgets::table::{Row, Table};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::LookupSpan;
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct CapturedSpan {
name: String,
fields: HashMap<String, String>,
parent_name: Option<String>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct SpanTiming {
name: String,
enter: Instant,
duration: Option<std::time::Duration>,
}
struct SpanCapture {
spans: Arc<Mutex<Vec<CapturedSpan>>>,
timings: Arc<Mutex<HashMap<tracing::span::Id, SpanTiming>>>,
completed_timings: Arc<Mutex<Vec<SpanTiming>>>,
}
impl SpanCapture {
fn new() -> (Self, CaptureHandle) {
let spans = Arc::new(Mutex::new(Vec::new()));
let timings = Arc::new(Mutex::new(HashMap::new()));
let completed_timings = Arc::new(Mutex::new(Vec::new()));
let handle = CaptureHandle {
spans: spans.clone(),
completed_timings: completed_timings.clone(),
};
let layer = Self {
spans,
timings,
completed_timings,
};
(layer, handle)
}
}
#[allow(dead_code)]
struct CaptureHandle {
spans: Arc<Mutex<Vec<CapturedSpan>>>,
completed_timings: Arc<Mutex<Vec<SpanTiming>>>,
}
impl CaptureHandle {
fn spans(&self) -> Vec<CapturedSpan> {
self.spans.lock().unwrap().clone()
}
#[allow(dead_code)]
fn timings(&self) -> Vec<SpanTiming> {
self.completed_timings.lock().unwrap().clone()
}
}
struct FieldVisitor(Vec<(String, String)>);
impl tracing::field::Visit for FieldVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.0
.push((field.name().to_string(), format!("{value:?}")));
}
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
self.0.push((field.name().to_string(), value.to_string()));
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.0.push((field.name().to_string(), value.to_string()));
}
}
impl<S> tracing_subscriber::Layer<S> for SpanCapture
where
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
{
fn on_new_span(
&self,
attrs: &tracing::span::Attributes<'_>,
_id: &tracing::span::Id,
ctx: tracing_subscriber::layer::Context<'_, S>,
) {
let mut visitor = FieldVisitor(Vec::new());
attrs.record(&mut visitor);
let parent_name = ctx
.current_span()
.id()
.and_then(|id| ctx.span(id))
.map(|span_ref| span_ref.name().to_string());
let fields: HashMap<String, String> = visitor.0.into_iter().collect();
self.spans.lock().unwrap().push(CapturedSpan {
name: attrs.metadata().name().to_string(),
fields,
parent_name,
});
}
fn on_enter(&self, id: &tracing::span::Id, ctx: tracing_subscriber::layer::Context<'_, S>) {
if let Some(span_ref) = ctx.span(id) {
self.timings.lock().unwrap().insert(
id.clone(),
SpanTiming {
name: span_ref.name().to_string(),
enter: Instant::now(),
duration: None,
},
);
}
}
fn on_exit(&self, id: &tracing::span::Id, _ctx: tracing_subscriber::layer::Context<'_, S>) {
let mut timings = self.timings.lock().unwrap();
if let Some(timing) = timings.get_mut(id) {
timing.duration = Some(timing.enter.elapsed());
}
}
fn on_close(&self, id: tracing::span::Id, _ctx: tracing_subscriber::layer::Context<'_, S>) {
if let Some(timing) = self.timings.lock().unwrap().remove(&id) {
self.completed_timings.lock().unwrap().push(timing);
}
}
}
fn with_captured_spans<F>(f: F) -> CaptureHandle
where
F: FnOnce(),
{
let (layer, handle) = SpanCapture::new();
let subscriber = tracing_subscriber::registry().with(layer);
tracing::subscriber::with_default(subscriber, f);
handle
}
#[test]
#[cfg(feature = "tracing")]
fn spans_created_for_render_phases() {
let handle = with_captured_spans(|| {
let area = Rect::new(0, 0, 20, 10);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 10, &mut pool);
Block::bordered().render(area, &mut frame);
Paragraph::new(ftui_text::Text::raw("Hello")).render(area, &mut frame);
let table = Table::new(
[Row::new(["A", "B"])],
[Constraint::Fixed(5), Constraint::Fixed(5)],
);
Widget::render(&table, area, &mut frame);
});
let spans = handle.spans();
let widget_spans: Vec<_> = spans.iter().filter(|s| s.name == "widget_render").collect();
assert!(
widget_spans.len() >= 3,
"Expected at least 3 widget_render spans, got {}",
widget_spans.len()
);
let widget_names: Vec<_> = widget_spans
.iter()
.filter_map(|s| s.fields.get("widget"))
.collect();
assert!(
widget_names.iter().any(|n| n.contains("Block")),
"Should have a Block span, got: {widget_names:?}"
);
assert!(
widget_names.iter().any(|n| n.contains("Paragraph")),
"Should have a Paragraph span, got: {widget_names:?}"
);
assert!(
widget_names.iter().any(|n| n.contains("Table")),
"Should have a Table span, got: {widget_names:?}"
);
}
#[test]
#[cfg(feature = "tracing")]
fn span_timing_accurate() {
let handle = with_captured_spans(|| {
let area = Rect::new(0, 0, 80, 24);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(80, 24, &mut pool);
let table = Table::new(
(0..20).map(|i| Row::new([format!("Row {i}"), format!("Value {}", i * 2)])),
[Constraint::Fixed(20), Constraint::Fixed(20)],
)
.header(Row::new(["Name", "Value"]))
.block(Block::bordered());
Widget::render(&table, area, &mut frame);
});
let timings = handle.timings();
let widget_timings: Vec<_> = timings
.iter()
.filter(|t| t.name == "widget_render")
.collect();
assert!(
!widget_timings.is_empty(),
"Should have widget_render timing records"
);
for timing in &widget_timings {
let duration = timing.duration.expect("span should have a duration");
assert!(
duration < std::time::Duration::from_secs(1),
"Widget render took unreasonably long: {duration:?}",
);
}
}
#[test]
#[cfg(feature = "tracing")]
fn spans_nest_correctly() {
let handle = with_captured_spans(|| {
let area = Rect::new(0, 0, 20, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 5, &mut pool);
let table =
Table::new([Row::new(["Data"])], [Constraint::Fixed(10)]).block(Block::bordered());
Widget::render(&table, area, &mut frame);
});
let spans = handle.spans();
let widget_spans: Vec<_> = spans.iter().filter(|s| s.name == "widget_render").collect();
let block_span = widget_spans
.iter()
.find(|s| s.fields.get("widget").is_some_and(|w| w.contains("Block")));
assert!(block_span.is_some(), "Should have a Block widget span");
let block_span = block_span.unwrap();
assert_eq!(
block_span.parent_name.as_deref(),
Some("widget_render"),
"Block span should be nested under Table's widget_render span"
);
}
#[test]
fn zero_overhead_when_disabled() {
let handle = with_captured_spans(|| {
let area = Rect::new(0, 0, 20, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 5, &mut pool);
Block::bordered().render(area, &mut frame);
Paragraph::new(ftui_text::Text::raw("test")).render(area, &mut frame);
let table = Table::new([Row::new(["X"])], [Constraint::Fixed(5)]);
Widget::render(&table, area, &mut frame);
});
let spans = handle.spans();
let widget_spans: Vec<_> = spans.iter().filter(|s| s.name == "widget_render").collect();
#[cfg(feature = "tracing")]
assert!(
!widget_spans.is_empty(),
"With tracing feature, widget_render spans should be present"
);
#[cfg(not(feature = "tracing"))]
assert!(
widget_spans.is_empty(),
"Without tracing feature, no widget_render spans should exist (got {})",
widget_spans.len()
);
}
#[test]
#[cfg(feature = "tracing")]
fn tracing_subscriber_receives_all_spans() {
let handle = with_captured_spans(|| {
let area = Rect::new(0, 0, 40, 10);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 10, &mut pool);
Block::bordered().render(area, &mut frame);
Paragraph::new(ftui_text::Text::raw("Hello")).render(area, &mut frame);
let table = Table::new([Row::new(["A"])], [Constraint::Fixed(10)]);
Widget::render(&table, area, &mut frame);
use ftui_widgets::spinner::{Spinner, SpinnerState};
let spinner = Spinner::new();
let mut state = SpinnerState::default();
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
use ftui_widgets::progress::ProgressBar;
ProgressBar::new().ratio(0.5).render(area, &mut frame);
use ftui_widgets::rule::Rule;
Rule::new().render(area, &mut frame);
use ftui_widgets::scrollbar::{Scrollbar, ScrollbarOrientation, ScrollbarState};
let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
let mut sb_state = ScrollbarState::new(100, 0, 10);
StatefulWidget::render(&sb, area, &mut frame, &mut sb_state);
use ftui_widgets::list::{List, ListItem, ListState};
let list = List::new([ListItem::new("item")]);
let mut list_state = ListState::default();
StatefulWidget::render(&list, area, &mut frame, &mut list_state);
use ftui_widgets::input::TextInput;
TextInput::new()
.with_value("hello")
.render(area, &mut frame);
});
let spans = handle.spans();
let widget_names: Vec<String> = spans
.iter()
.filter(|s| s.name == "widget_render")
.filter_map(|s| s.fields.get("widget").cloned())
.collect();
let expected = [
"Block",
"Paragraph",
"Table",
"Spinner",
"ProgressBar",
"Rule",
"Scrollbar",
"List",
"TextInput",
];
for name in &expected {
assert!(
widget_names.iter().any(|n| n.contains(name)),
"Missing widget_render span for {name}. Got: {widget_names:?}"
);
}
}
#[test]
#[cfg(feature = "tracing")]
fn real_render_loop_traced() {
use ftui_render::diff::BufferDiff;
let handle = with_captured_spans(|| {
let area = Rect::new(0, 0, 40, 10);
let current = Buffer::new(40, 10);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 10, &mut pool);
let table = Table::new(
[Row::new(["Name", "Value"]), Row::new(["foo", "42"])],
[Constraint::Fixed(15), Constraint::Fixed(15)],
)
.header(Row::new(["Col A", "Col B"]))
.block(Block::bordered());
Widget::render(&table, area, &mut frame);
let next = frame.buffer;
let diff = BufferDiff::compute(¤t, &next);
assert!(!diff.is_empty(), "Should have changes");
let _runs = diff.runs();
});
let spans = handle.spans();
let widget_spans: Vec<_> = spans.iter().filter(|s| s.name == "widget_render").collect();
assert!(
!widget_spans.is_empty(),
"Should have widget_render spans from rendering"
);
let diff_spans: Vec<_> = spans.iter().filter(|s| s.name == "diff_compute").collect();
assert!(
!diff_spans.is_empty(),
"Should have diff_compute span from BufferDiff::compute"
);
let diff_span = &diff_spans[0];
assert!(
diff_span.fields.contains_key("width"),
"diff_compute span should have width field"
);
assert!(
diff_span.fields.contains_key("height"),
"diff_compute span should have height field"
);
let runs_spans: Vec<_> = spans.iter().filter(|s| s.name == "diff_runs").collect();
assert!(
!runs_spans.is_empty(),
"Should have diff_runs span from runs()"
);
}
#[test]
#[cfg(feature = "tracing")]
fn span_fields_contain_area_dimensions() {
let handle = with_captured_spans(|| {
let area = Rect::new(5, 10, 30, 15);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 30, &mut pool);
Block::bordered().render(area, &mut frame);
});
let spans = handle.spans();
let block_span = spans
.iter()
.find(|s| {
s.name == "widget_render" && s.fields.get("widget").is_some_and(|w| w.contains("Block"))
})
.expect("Should have a Block widget_render span");
assert_eq!(block_span.fields.get("x").map(String::as_str), Some("5"));
assert_eq!(block_span.fields.get("y").map(String::as_str), Some("10"));
assert_eq!(block_span.fields.get("w").map(String::as_str), Some("30"));
assert_eq!(block_span.fields.get("h").map(String::as_str), Some("15"));
}