use std::cell::RefCell;
use std::rc::Rc;
use cranpose_ui::text::{AnnotatedString, TextLayoutResult};
use cranpose_ui::{
collect_slices_from_modifier, execute_draw_commands, set_text_measurer, AppContext,
DrawCommand, Modifier, TextMeasurer, TextMetrics, TextStyle,
};
use cranpose_ui_graphics::{
estimate_text_measurement, Brush, Color, DrawPrimitive, DrawScope, Point, Rect, Size,
TextAlign, TextStyle as DrawTextStyle, TextVerticalAlign,
};
const CHAR_WIDTH: f32 = 6.0;
const LINE_HEIGHT: f32 = 10.0;
const FIRST_BASELINE: f32 = 8.0;
struct RecordingMeasurer {
measured: Rc<RefCell<Vec<String>>>,
}
impl TextMeasurer for RecordingMeasurer {
fn measure(&self, text: &AnnotatedString, _style: &TextStyle) -> TextMetrics {
self.measured.borrow_mut().push(text.text.clone());
let line_count = text.text.split('\n').count().max(1);
let width = text
.text
.split('\n')
.map(|line| line.chars().count() as f32 * CHAR_WIDTH)
.fold(0.0_f32, f32::max);
TextMetrics {
width,
height: line_count as f32 * LINE_HEIGHT,
line_height: LINE_HEIGHT,
line_count,
}
}
fn first_baseline(&self, _style: &TextStyle) -> Option<f32> {
Some(FIRST_BASELINE)
}
fn get_offset_for_position(
&self,
_text: &AnnotatedString,
_style: &TextStyle,
_x: f32,
_y: f32,
) -> usize {
0
}
fn get_cursor_x_for_offset(
&self,
_text: &AnnotatedString,
_style: &TextStyle,
_offset: usize,
) -> f32 {
0.0
}
fn layout(&self, text: &AnnotatedString, _style: &TextStyle) -> TextLayoutResult {
TextLayoutResult::monospaced(&text.text, CHAR_WIDTH, LINE_HEIGHT)
}
}
fn primitives_from_draw_behind(
size: Size,
draw: impl Fn(&mut dyn DrawScope) + 'static,
) -> (Vec<DrawPrimitive>, Vec<String>) {
let app_context = AppContext::new();
let measured = Rc::new(RefCell::new(Vec::new()));
let commands: Vec<DrawCommand> =
collect_slices_from_modifier(&Modifier::empty().draw_behind(draw))
.draw_commands()
.to_vec();
let primitives = app_context.enter(|| {
set_text_measurer(RecordingMeasurer {
measured: Rc::clone(&measured),
});
execute_draw_commands(&commands, size)
});
let measured = measured.borrow().clone();
(primitives, measured)
}
fn text_primitive(primitives: &[DrawPrimitive]) -> &cranpose_ui_graphics::TextPrimitive {
match primitives.first() {
Some(DrawPrimitive::Text(text)) => text,
other => panic!("expected a text primitive, got {other:?}"),
}
}
#[test]
fn draw_behind_text_is_measured_by_the_app_context_measurer() {
let (primitives, measured) = primitives_from_draw_behind(Size::new(200.0, 100.0), |scope| {
scope.draw_text_from(
Point::new(4.0, 5.0),
Brush::solid(Color::WHITE),
"SCORE",
&DrawTextStyle::new(12.0),
);
});
assert_eq!(measured, vec!["SCORE".to_string()]);
let text = text_primitive(&primitives);
assert_eq!(
text.rect,
Rect {
x: 4.0,
y: 5.0,
width: 5.0 * CHAR_WIDTH,
height: LINE_HEIGHT,
}
);
assert_ne!(
text.rect.width,
estimate_text_measurement("SCORE", &DrawTextStyle::new(12.0))
.size
.width
);
}
#[test]
fn measure_text_reports_the_box_a_later_draw_fills() {
let reported: Rc<RefCell<Option<cranpose_ui_graphics::TextMeasurement>>> =
Rc::new(RefCell::new(None));
let sink = Rc::clone(&reported);
let (primitives, _) = primitives_from_draw_behind(Size::new(300.0, 60.0), move |scope| {
let style = DrawTextStyle::new(12.0).with_align(TextAlign::Center);
*sink.borrow_mut() = Some(scope.measure_text("GAME OVER", &style));
scope.draw_text(Brush::solid(Color::WHITE), "GAME OVER", &style);
});
let measurement = reported.borrow().expect("measurement");
let text = text_primitive(&primitives);
assert_eq!(text.rect.width, measurement.size.width);
assert_eq!(text.rect.height, measurement.size.height);
assert_eq!(text.rect.x, (300.0 - measurement.size.width) / 2.0);
}
#[test]
fn baseline_alignment_uses_the_measurers_baseline() {
let (primitives, _) = primitives_from_draw_behind(Size::new(200.0, 100.0), |scope| {
scope.draw_text_at(
Rect {
x: 0.0,
y: 50.0,
width: 200.0,
height: 0.0,
},
Brush::solid(Color::WHITE),
"Ag",
&DrawTextStyle::new(12.0).with_vertical_align(TextVerticalAlign::Baseline),
);
});
assert_eq!(text_primitive(&primitives).rect.y, 50.0 - FIRST_BASELINE);
}
#[test]
fn an_unchanged_string_measures_once_across_repeated_frames() {
let app_context = AppContext::new();
let measured = Rc::new(RefCell::new(Vec::new()));
let commands: Vec<DrawCommand> = collect_slices_from_modifier(&Modifier::empty().draw_behind(
|scope: &mut dyn DrawScope| {
scope.draw_text_from(
Point::ZERO,
Brush::solid(Color::WHITE),
"1234",
&DrawTextStyle::new(12.0),
);
},
))
.draw_commands()
.to_vec();
app_context.enter(|| {
set_text_measurer(RecordingMeasurer {
measured: Rc::clone(&measured),
});
for _ in 0..60 {
execute_draw_commands(&commands, Size::new(100.0, 50.0));
}
});
assert_eq!(
measured.borrow().len(),
1,
"a stable string must be shaped once and served from the metrics cache after that"
);
}
#[test]
fn text_that_cannot_be_measured_never_reaches_the_primitive_stream() {
let (primitives, measured) = primitives_from_draw_behind(Size::new(100.0, 100.0), |scope| {
let style = DrawTextStyle::new(12.0);
scope.draw_text(Brush::solid(Color::WHITE), "", &style);
scope.draw_text(Brush::solid(Color(1.0, 1.0, 1.0, 0.0)), "hidden", &style);
});
assert!(primitives.is_empty());
assert!(
measured.is_empty(),
"neither empty nor invisible text should cost a measurement"
);
}