#![allow(clippy::similar_names)]
use kurbo::{Point, Rect};
use crate::geom;
use crate::vt::{Config, Layout, Metrics, Section, word_width};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Place {
pub section: u32,
pub line: u32,
pub word: Option<u32>,
}
impl Place {
#[must_use]
pub fn new(section: u32, line: u32, word: Option<u32>) -> Place {
Place {
section,
line,
word,
}
}
pub const START: Place = Place {
section: 0,
line: 0,
word: None,
};
#[must_use]
pub fn start() -> Place {
Place::START
}
}
impl Default for Place {
fn default() -> Place {
Place::START
}
}
#[must_use]
pub fn begin_place(layout: &Layout) -> Place {
let _ = layout;
Place::start()
}
#[must_use]
pub fn end_place(layout: &Layout) -> Place {
let Some(section) = layout.sections.len().checked_sub(1) else {
return Place::new(0, 0, None);
};
let Some(last) = layout.sections.get(section) else {
return Place::new(0, 0, None);
};
end_of_section(last, section)
}
fn end_of_section(section: &Section, index: usize) -> Place {
let Some(line_index) = section.lines.len().checked_sub(1) else {
return Place::new(clamp_index(index), 0, None);
};
let word = section
.lines
.get(line_index)
.and_then(crate::vt::Line::last_word);
Place::new(clamp_index(index), clamp_index(line_index), word)
}
fn clamp_index(index: usize) -> u32 {
u32::try_from(index).unwrap_or(u32::MAX)
}
#[must_use]
pub fn place_at_point(
layout: &Layout,
plate: Rect,
config: &Config,
metrics: &Metrics<'_>,
offset: (f32, f32),
point: Point,
) -> Place {
#[allow(clippy::cast_possible_truncation)]
let x = point.x as f32 - offset.0 - geom::left(plate);
#[allow(clippy::cast_possible_truncation)]
let y = geom::top(plate) - (point.y as f32 - offset.1);
match find_section(layout, y) {
Found::Inside(index, section) => {
let mut place = place_in_section(section, config, metrics, layout.font_size, x, y);
place.section = clamp_index(index);
place
}
Found::Above => begin_place(layout),
Found::Below => end_place(layout),
}
}
enum Found<'a> {
Inside(usize, &'a Section),
Above,
Below,
}
fn find_section(layout: &Layout, y: f32) -> Found<'_> {
let mut any_above = false;
for (index, section) in layout.sections.iter().enumerate() {
let (top, bottom) = (geom::bottom(section.rect), geom::top(section.rect));
if geom::is_float_smaller(y, top) {
return if any_above {
Found::Below
} else {
Found::Above
};
}
if geom::is_float_bigger(y, bottom) {
any_above = true;
continue;
}
return Found::Inside(index, section);
}
Found::Below
}
fn place_in_section(
section: &Section,
config: &Config,
metrics: &Metrics<'_>,
font_size: f32,
x: f32,
y: f32,
) -> Place {
let Some((index, line)) = find_line(section, y) else {
return if section.lines.is_empty() {
Place::new(0, 0, None)
} else {
end_of_section(section, 0)
};
};
Place::new(
0,
clamp_index(index),
word_at_x(section, line_range(line), config, metrics, font_size, x),
)
}
fn find_line(section: &Section, y: f32) -> Option<(usize, &crate::vt::Line)> {
let mut last: Option<(usize, &crate::vt::Line)> = None;
for (index, line) in section.lines.iter().enumerate() {
let top = line.y - line.ascent;
let bottom = line.y - line.descent;
if geom::is_float_smaller(y, top) {
return Some(last.unwrap_or((index, line)));
}
if geom::is_float_bigger(y, bottom) {
last = Some((index, line));
continue;
}
return Some((index, line));
}
last
}
fn line_range(line: &crate::vt::Line) -> std::ops::Range<usize> {
let Some(words) = line.words.clone() else {
return 0..0;
};
let begin = usize::try_from(words.start).unwrap_or(usize::MAX);
let end = usize::try_from(words.end).unwrap_or(usize::MAX);
begin..end.max(begin)
}
fn word_at_x(
section: &Section,
range: std::ops::Range<usize>,
config: &Config,
metrics: &Metrics<'_>,
font_size: f32,
x: f32,
) -> Option<u32> {
let past_midpoint = |index: usize| {
section
.words
.get(index)
.is_some_and(|word| x > word.x + word_width(word, config, metrics, font_size) * 0.5)
};
if range.is_empty() {
return None;
}
let (mut left, mut right) = (range.start, range.end);
let mut mid = left.saturating_add(right) / 2;
while left < right {
if mid == left {
break;
}
if mid == right {
mid = mid.saturating_sub(1);
break;
}
if section.words.get(mid).is_none() {
break;
}
if past_midpoint(mid) {
left = mid;
} else {
right = mid;
}
mid = left.saturating_add(right) / 2;
}
if past_midpoint(mid) {
return Some(u32::try_from(mid).unwrap_or(u32::MAX));
}
None
}
#[must_use]
pub fn point_at_place(
layout: &Layout,
plate: Rect,
config: &Config,
metrics: &Metrics<'_>,
offset: (f32, f32),
place: Place,
) -> Point {
let (x, y) = caret_position(layout, config, metrics, place);
let (x, y) = Layout::to_pdf(plate, x, y);
Point::new(f64::from(x + offset.0), f64::from(y + offset.1))
}
#[must_use]
pub fn caret_rect(
layout: &Layout,
plate: Rect,
config: &Config,
metrics: &Metrics<'_>,
offset: (f32, f32),
place: Place,
width: f32,
) -> Rect {
let (x, top) = caret_position(layout, config, metrics, place);
let height = line_extent(layout, config, metrics, place);
let (left, top) = Layout::to_pdf(plate, x, top);
let (left, top) = (left + offset.0, top + offset.1);
geom::rect(left, top - height, left + width, top)
}
fn caret_position(
layout: &Layout,
config: &Config,
metrics: &Metrics<'_>,
place: Place,
) -> (f32, f32) {
let Some(section) = layout.sections.get(place.section as usize) else {
return (0.0, 0.0);
};
let Some(line) = section.lines.get(place.line as usize) else {
return (0.0, 0.0);
};
let top = line.y - line.ascent;
let Some(word_index) = place.word else {
return (line_caret_x(section, line), top);
};
let index = usize::try_from(word_index).unwrap_or(usize::MAX);
let Some(word) = section.words.get(index) else {
return (line_caret_x(section, line), top);
};
(caret_x(word, config, metrics, layout.font_size), top)
}
fn caret_x(word: &crate::vt::Word, config: &Config, metrics: &Metrics<'_>, font_size: f32) -> f32 {
if word.is_rtl {
word.x
} else {
word.x + word_width(word, config, metrics, font_size)
}
}
fn line_caret_x(section: &Section, line: &crate::vt::Line) -> f32 {
let Some(words) = line.words.clone() else {
return line.x;
};
let first = (words.start < words.end)
.then(|| usize::try_from(words.start).ok())
.flatten()
.and_then(|index| section.words.get(index));
if first.is_some_and(|word| word.is_rtl) {
line.x + line.width
} else {
line.x
}
}
fn line_extent(layout: &Layout, config: &Config, metrics: &Metrics<'_>, place: Place) -> f32 {
let fallback = || {
crate::vt::font_ascent(metrics, config.font_size)
- crate::vt::font_descent(metrics, config.font_size)
};
layout
.sections
.get(place.section as usize)
.and_then(|section| section.lines.get(place.line as usize))
.map_or_else(fallback, |line| line.ascent - line.descent)
}
#[must_use]
pub fn word_index_of_place(layout: &Layout, place: Place) -> usize {
if place.word.is_none() && place.line > 0 {
let previous = layout
.sections
.get(place.section as usize)
.and_then(|section| section.lines.get(place.line as usize))
.map(|line| {
let before = line
.words
.as_ref()
.and_then(|words| words.start.checked_sub(1));
Place::new(place.section, place.line - 1, before)
});
if let Some(previous) = previous {
return word_index_of_place(layout, previous);
}
}
let target = place.section as usize;
let last = layout.sections.len().saturating_sub(1);
let mut index: usize = 0;
for (position, section) in layout.sections.iter().enumerate() {
if position >= target {
break;
}
index = index.saturating_add(section.words.len());
if position != last {
index = index.saturating_add(SECTION_BREAK_LENGTH);
}
}
let Some(section) = layout.sections.get(target) else {
return index;
};
let after = place.word.map_or(0, |word| {
usize::try_from(word)
.unwrap_or(usize::MAX)
.saturating_add(1)
});
index.saturating_add(after.min(section.words.len()))
}
const SECTION_BREAK_LENGTH: usize = 1;
#[must_use]
pub fn place_of_word_index(layout: &Layout, index: usize) -> Place {
let mut consumed: usize = 0;
for (position, section) in layout.sections.iter().enumerate() {
let end = consumed.saturating_add(section.words.len());
if index <= end {
let within = index.saturating_sub(consumed);
let word = within
.checked_sub(1)
.map(|w| u32::try_from(w).unwrap_or(u32::MAX));
return place_of_word(section, position, word);
}
consumed = end.saturating_add(SECTION_BREAK_LENGTH);
}
end_place(layout)
}
fn place_of_word(section: &Section, index: usize, word: Option<u32>) -> Place {
let line = line_of_word(section, word);
Place::new(clamp_index(index), line, word)
}
fn line_of_word(section: &Section, word: Option<u32>) -> u32 {
let Some(word) = word else {
return 0;
};
for (index, line) in section.lines.iter().enumerate() {
if line
.words
.as_ref()
.is_some_and(|words| words.contains(&word))
{
return clamp_index(index);
}
}
clamp_index(section.lines.len().saturating_sub(1))
}
#[cfg(test)]
mod tests {
use super::{
Place, begin_place, caret_rect, end_place, place_at_point, place_of_word_index,
point_at_place, word_index_of_place,
};
fn carets(n: u32) -> impl Iterator<Item = Option<u32>> {
std::iter::once(None).chain((0..n).map(Some))
}
use crate::geom;
use crate::vt::{self, Config, Metrics};
use kurbo::Point;
fn config() -> Config {
Config {
plate: geom::rect(101.0, 101.0, 199.0, 129.0),
font_size: 12.0,
..Config::default()
}
}
fn helvetica(ch: u32) -> i32 {
match char::from_u32(ch) {
Some('A' | 'B' | 'E') => 667,
Some('C' | 'D' | 'H') => 722,
Some('F') => 611,
Some('G') => 778,
Some(' ') => 278,
_ => 556,
}
}
fn metrics() -> Metrics<'static> {
Metrics {
width: &helvetica,
ascent: 723,
descent: -207,
}
}
fn ten(_: u32) -> i32 {
10
}
fn tens() -> Metrics<'static> {
Metrics {
width: &ten,
ascent: 1000,
descent: -200,
}
}
fn centred(layout: &vt::Layout, config: &Config) -> (f32, f32) {
let content = layout.content_rect_pdf(config.plate);
(
0.0,
(geom::height(content) - geom::height(config.plate)) * 0.5,
)
}
fn at(config: &Config, metrics: &Metrics<'_>, text: &str, x: f32, y: f32) -> Place {
let layout = vt::layout(text, config, metrics);
let offset = centred(&layout, config);
place_at_point(
&layout,
config.plate,
config,
metrics,
offset,
Point::new(f64::from(x), f64::from(y)),
)
}
#[test]
fn the_embeddertests_middle_click_lands_after_four_characters() {
let config = config();
let metrics = metrics();
let layout = vt::layout("ABCDEFGH", &config, &metrics);
let place = place_at_point(
&layout,
config.plate,
&config,
&metrics,
centred(&layout, &config),
Point::new(134.0, 115.0),
);
assert_eq!(place.word, Some(3), "the caret sits after D");
assert_eq!(
word_index_of_place(&layout, place),
4,
"four characters precede the caret"
);
}
#[test]
fn the_same_field_clicked_at_either_end_gives_the_two_extremes() {
let config = config();
let metrics = metrics();
let layout = vt::layout("ABCDEFGH", &config, &metrics);
let offset = centred(&layout, &config);
let begin = place_at_point(
&layout,
config.plate,
&config,
&metrics,
offset,
Point::new(102.0, 115.0),
);
assert_eq!(begin.word, None, "before the first character");
assert_eq!(word_index_of_place(&layout, begin), 0);
let end = place_at_point(
&layout,
config.plate,
&config,
&metrics,
offset,
Point::new(166.0, 115.0),
);
assert_eq!(end.word, Some(7), "after the last character");
assert_eq!(word_index_of_place(&layout, end), 8);
}
#[test]
fn a_click_exactly_on_a_midpoint_lands_before_that_character() {
let config = config();
let metrics = tens();
let config = Config {
font_size: 1000.0,
..config
};
assert_eq!(at(&config, &metrics, "abc", 106.0, 115.0).word, None);
assert_eq!(at(&config, &metrics, "abc", 106.001, 115.0).word, Some(0));
assert_eq!(at(&config, &metrics, "abc", 105.999, 115.0).word, None);
assert_eq!(at(&config, &metrics, "abc", 116.0, 115.0).word, Some(0));
assert_eq!(at(&config, &metrics, "abc", 116.001, 115.0).word, Some(1));
}
#[test]
fn points_outside_the_content_clamp_to_its_ends() {
let config = config();
let metrics = metrics();
let layout = vt::layout("ABCDEFGH", &config, &metrics);
let offset = centred(&layout, &config);
let ask = |x: f32, y: f32| {
place_at_point(
&layout,
config.plate,
&config,
&metrics,
offset,
Point::new(f64::from(x), f64::from(y)),
)
};
assert_eq!(ask(-1000.0, 115.0).word, None);
assert_eq!(ask(1000.0, 115.0).word, Some(7));
assert_eq!(ask(134.0, 10_000.0), begin_place(&layout));
assert_eq!(ask(134.0, -10_000.0), end_place(&layout));
}
#[test]
fn an_empty_field_answers_its_only_place_from_anywhere() {
let config = config();
let metrics = metrics();
let layout = vt::layout("", &config, &metrics);
for (x, y) in [
(102.0, 115.0),
(198.0, 115.0),
(134.0, 10_000.0),
(134.0, -10_000.0),
(-500.0, 115.0),
] {
let place = place_at_point(
&layout,
config.plate,
&config,
&metrics,
centred(&layout, &config),
Point::new(x, y),
);
assert_eq!(place.word, None, "at ({x}, {y})");
assert_eq!(word_index_of_place(&layout, place), 0);
}
}
#[test]
fn a_blank_line_between_paragraphs_is_its_own_place() {
let config = Config {
plate: geom::rect(0.0, 0.0, 200.0, 200.0),
font_size: 10.0,
multi_line: true,
..Config::default()
};
let metrics = tens();
let layout = vt::layout("ab\n\ncd", &config, &metrics);
assert_eq!(layout.sections.len(), 3, "three paragraphs");
assert!(
layout.sections.get(1).is_some_and(|s| s.words.is_empty()),
"the middle one is empty"
);
let blank = place_of_word_index(&layout, 3);
assert_eq!(blank, Place::new(1, 0, None));
assert_eq!(word_index_of_place(&layout, blank), 3);
}
#[test]
fn a_default_place_is_the_start_and_not_a_zero_word() {
assert_eq!(Place::default(), Place::START);
assert_eq!(Place::default(), Place::start());
assert_eq!(Place::START.word, None, "a zero word is after character 0");
}
#[test]
fn a_wrapped_lines_header_indexes_to_the_end_of_the_line_above() {
let config = Config {
plate: geom::rect(0.0, 0.0, 0.45, 200.0),
font_size: 10.0,
multi_line: true,
auto_return: true,
..Config::default()
};
let layout = vt::layout("abcdefgh", &config, &tens());
let section = layout.sections.first().expect("one section");
assert!(section.lines.len() >= 2, "the text must wrap: {section:?}");
let first_line_end = section.lines.first().expect("a first line").last_word();
let header = Place::new(0, 1, None);
assert_eq!(
word_index_of_place(&layout, header),
word_index_of_place(&layout, Place::new(0, 0, first_line_end)),
"the header must be the line above's end, not the field's start"
);
assert_ne!(word_index_of_place(&layout, header), 0);
}
#[test]
fn a_place_past_the_last_section_indexes_to_the_very_end() {
let config = Config {
plate: geom::rect(0.0, 0.0, 200.0, 200.0),
font_size: 10.0,
multi_line: true,
..Config::default()
};
for text in ["abc", "ab\ncd", "a\nb\nc"] {
let layout = vt::layout(text, &config, &tens());
assert_eq!(
word_index_of_place(&layout, Place::new(9, 0, Some(0))),
word_index_of_place(&layout, end_place(&layout)),
"{text:?}"
);
}
}
#[test]
fn every_place_survives_the_index_round_trip() {
let config = Config {
plate: geom::rect(0.0, 0.0, 200.0, 200.0),
font_size: 10.0,
multi_line: true,
..Config::default()
};
let metrics = tens();
for text in ["", "a", "abc", "ab\ncd", "ab\n\ncd", "a\nb\nc\nd"] {
let layout = vt::layout(text, &config, &metrics);
for (s, section) in layout.sections.iter().enumerate() {
for (l, line) in section.lines.iter().enumerate() {
let words = std::iter::once(None)
.chain(line.words.clone().into_iter().flatten().map(Some));
for word in words {
let place = Place::new(
u32::try_from(s).unwrap_or(0),
u32::try_from(l).unwrap_or(0),
word,
);
let index = word_index_of_place(&layout, place);
let expected = if word.is_none() && l > 0 {
let previous = section.lines.get(l - 1).expect("a line above");
place_of_word_index(
&layout,
word_index_of_place(
&layout,
Place::new(
u32::try_from(s).unwrap_or(0),
u32::try_from(l - 1).unwrap_or(0),
previous.last_word(),
),
),
)
} else {
place
};
assert_eq!(
place_of_word_index(&layout, index),
expected,
"{text:?} at {place:?} (index {index})"
);
}
}
}
}
}
#[test]
fn an_index_past_the_end_answers_the_last_place() {
let config = config();
let metrics = metrics();
let layout = vt::layout("ABC", &config, &metrics);
assert_eq!(place_of_word_index(&layout, 3), end_place(&layout));
assert_eq!(place_of_word_index(&layout, 9999), end_place(&layout));
}
#[test]
fn a_caret_clicked_at_its_own_position_does_not_move() {
let config = config();
let metrics = metrics();
let layout = vt::layout("ABCDEFGH", &config, &metrics);
let offset = centred(&layout, &config);
for word in carets(8) {
let place = Place::new(0, 0, word);
let point = point_at_place(&layout, config.plate, &config, &metrics, offset, place);
let back = place_at_point(
&layout,
config.plate,
&config,
&metrics,
offset,
Point::new(point.x, point.y - 1.0),
);
assert_eq!(back, place, "caret at {word:?} moved");
}
}
#[test]
fn the_caret_rectangle_spans_its_line() {
let config = config();
let metrics = metrics();
let layout = vt::layout("ABCDEFGH", &config, &metrics);
let rect = caret_rect(
&layout,
config.plate,
&config,
&metrics,
(0.0, 0.0),
Place::new(0, 0, Some(3)),
0.4,
);
assert!(
(geom::left(rect) - 134.336).abs() < 0.01,
"{:?}",
geom::left(rect)
);
assert!((geom::width(rect) - 0.4).abs() < 1e-5);
let expected = (723.0 + 207.0) * 12.0 * 0.001;
assert!(
(geom::height(rect) - expected).abs() < 0.01,
"{:?} vs {expected}",
geom::height(rect)
);
}
#[test]
fn a_place_past_the_layout_still_answers_a_point() {
let config = config();
let metrics = metrics();
let layout = vt::layout("AB", &config, &metrics);
for place in [
Place::new(9, 0, Some(0)),
Place::new(0, 9, Some(0)),
Place::new(0, 0, Some(99)),
Place::new(u32::MAX, u32::MAX, Some(u32::MAX)),
] {
let point = point_at_place(&layout, config.plate, &config, &metrics, (0.0, 0.0), place);
assert!(point.x.is_finite() && point.y.is_finite(), "{place:?}");
let index = word_index_of_place(&layout, place);
let back = place_of_word_index(&layout, index);
assert!(
(back.section as usize) < layout.sections.len(),
"{place:?} came back as {back:?}"
);
}
}
#[test]
fn comb_cells_hit_test_on_the_cell_rather_than_the_glyph() {
let config = Config {
plate: geom::rect(0.0, 0.0, 100.0, 20.0),
font_size: 10.0,
char_array: 4,
..Config::default()
};
let metrics = tens();
let layout = vt::layout("ab", &config, &metrics);
let first = layout
.sections
.first()
.and_then(|s| s.words.first())
.copied()
.expect("a first character");
let width = vt::word_width(&first, &config, &metrics, config.font_size);
assert!(width > 20.0, "the cell tail widened the advance: {width}");
let mid = first.x + width * 0.5;
let before = place_at_point(
&layout,
config.plate,
&config,
&metrics,
(0.0, 0.0),
Point::new(f64::from(mid) - 0.01, 10.0),
);
let after = place_at_point(
&layout,
config.plate,
&config,
&metrics,
(0.0, 0.0),
Point::new(f64::from(mid) + 0.01, 10.0),
);
assert_eq!(before.word, None);
assert_eq!(after.word, Some(0));
}
fn half_em(_: u32) -> i32 {
500
}
fn halves() -> Metrics<'static> {
Metrics {
width: &half_em,
ascent: 1000,
descent: -200,
}
}
fn rtl_run() -> (Config, Metrics<'static>, vt::Layout) {
let config = Config {
plate: geom::rect(1.0, 1.0, 99.0, 29.0),
font_size: 12.0,
..Config::default()
};
let metrics = halves();
let layout = vt::layout("\u{5D1}\u{5D7}\u{5E8}", &config, &metrics);
(config, metrics, layout)
}
#[test]
fn a_right_to_left_words_carets_walk_leftward_from_its_right_edge() {
let (config, metrics, layout) = rtl_run();
let got: Vec<f64> = carets(3)
.map(|word| {
point_at_place(
&layout,
config.plate,
&config,
&metrics,
(0.0, 0.0),
Place::new(0, 0, word),
)
.x
})
.collect();
let want = [19.0, 13.0, 7.0, 1.0];
for (place, (&got, &want)) in got.iter().zip(want.iter()).enumerate() {
assert!(
(got - want).abs() < 1e-4,
"place {} caret is {got}, want {want}: {got:?}",
i32::try_from(place).unwrap_or(0) - 1
);
}
}
#[test]
fn the_drawn_caret_rectangle_follows_the_same_right_to_left_walk() {
let (config, metrics, layout) = rtl_run();
for (word, want) in carets(3).zip([19.0_f64, 13.0, 7.0, 1.0]) {
let rect = caret_rect(
&layout,
config.plate,
&config,
&metrics,
(0.0, 0.0),
Place::new(0, 0, word),
0.4,
);
assert!(
(rect.x0 - want).abs() < 1e-4,
"caret after {word:?} starts at {}, want {want}",
rect.x0
);
assert!((rect.x1 - rect.x0 - 0.4).abs() < 1e-4, "{rect:?}");
}
}
#[test]
fn a_left_to_right_run_still_walks_rightward_from_its_left_edge() {
let config = Config {
plate: geom::rect(1.0, 1.0, 99.0, 29.0),
font_size: 12.0,
..Config::default()
};
let metrics = halves();
let layout = vt::layout("abc", &config, &metrics);
let got: Vec<f64> = carets(3)
.map(|word| {
point_at_place(
&layout,
config.plate,
&config,
&metrics,
(0.0, 0.0),
Place::new(0, 0, word),
)
.x
})
.collect();
for (place, (&got, &want)) in got.iter().zip([1.0, 7.0, 13.0, 19.0].iter()).enumerate() {
assert!(
(got - want).abs() < 1e-4,
"place {} caret is {got}, want {want}",
i32::try_from(place).unwrap_or(0) - 1
);
}
}
#[test]
fn a_click_in_a_right_to_left_run_answers_the_end_the_bisection_narrows_to() {
let (config, metrics, layout) = rtl_run();
let at = |x: f64| {
place_at_point(
&layout,
config.plate,
&config,
&metrics,
(0.0, 0.0),
Point::new(x, 15.0),
)
.word
};
let caret = |x: f64| {
point_at_place(
&layout,
config.plate,
&config,
&metrics,
(0.0, 0.0),
Place::new(0, 0, at(x)),
)
.x
};
assert_eq!(at(2.0), None, "left of the middle midpoint: the header");
assert_eq!(at(9.9), None);
assert_eq!(at(10.0), None, "exactly on the midpoint is not past it");
assert_eq!(at(10.1), Some(2), "past it: the run's last character");
assert_eq!(at(18.0), Some(2));
assert!((caret(2.0) - 19.0).abs() < 1e-4, "{}", caret(2.0));
assert!((caret(18.0) - 1.0).abs() < 1e-4, "{}", caret(18.0));
}
#[test]
fn a_mixed_line_takes_its_header_from_its_first_word_only() {
let config = Config {
plate: geom::rect(1.0, 1.0, 99.0, 29.0),
font_size: 12.0,
..Config::default()
};
let metrics = halves();
let rtl_first = vt::layout("\u{5D1}\u{5D7}ab", &config, &metrics);
let header = point_at_place(
&rtl_first,
config.plate,
&config,
&metrics,
(0.0, 0.0),
Place::START,
)
.x;
let line_width = rtl_first
.sections
.first()
.and_then(|section| section.lines.first())
.map_or(0.0, |line| line.width);
assert!(
(header - f64::from(1.0 + line_width)).abs() < 1e-4,
"header is the line's right edge: {header} against width {line_width}"
);
let ltr_first = vt::layout("ab\u{5D1}\u{5D7}", &config, &metrics);
let header = point_at_place(
<r_first,
config.plate,
&config,
&metrics,
(0.0, 0.0),
Place::START,
)
.x;
assert!((header - 1.0).abs() < 1e-4, "{header}");
}
#[test]
fn an_empty_line_keeps_its_left_edge_for_a_header() {
let config = Config {
plate: geom::rect(1.0, 1.0, 99.0, 29.0),
font_size: 12.0,
..Config::default()
};
let metrics = halves();
let layout = vt::layout("", &config, &metrics);
let header = point_at_place(
&layout,
config.plate,
&config,
&metrics,
(0.0, 0.0),
Place::START,
)
.x;
assert!((header - 1.0).abs() < 1e-4, "{header}");
}
fn password_field() -> (Config, Metrics<'static>, vt::Layout) {
fn arimo(ch: u32) -> i32 {
match char::from_u32(ch) {
Some('*') => 389,
Some('t') => 278,
Some('i') => 222,
Some('r') => 333,
_ => 556,
}
}
let config = Config {
plate: geom::rect(101.0, 161.0, 199.0, 189.0),
alignment: vt::Alignment::Right,
font_size: 0.0,
sub_word: Some('*'),
limit_char: 5,
..Config::default()
};
let metrics = Metrics {
width: &arimo,
ascent: 905,
descent: -211,
};
let layout = vt::layout("tigerssss", &config, &metrics);
(config, metrics, layout)
}
#[test]
fn an_auto_sized_fields_carets_are_measured_at_the_size_the_layout_resolved() {
let (config, metrics, layout) = password_field();
assert!(
(layout.font_size - 25.0).abs() < 1e-4,
"auto size resolved to {}",
layout.font_size
);
assert!(
config.font_size == 0.0,
"the config still holds the request, which is the whole point"
);
assert_eq!(end_place(&layout), Place::new(0, 0, Some(4)));
let advance = 389.0 * 25.0 / 1000.0;
let first = 150.375_f32;
for word in carets(5) {
let got = point_at_place(
&layout,
config.plate,
&config,
&metrics,
(0.0, 0.0),
Place::new(0, 0, word),
)
.x;
let steps = u16::try_from(word.map_or(0, |w| w.saturating_add(1)))
.map_or(f32::from(u16::MAX), f32::from);
let want = f64::from(first + advance * steps);
assert!(
(got - want).abs() < 1e-3,
"caret after {word:?} is {got}, want {want}"
);
}
}
#[test]
fn the_drawn_caret_of_an_auto_sized_field_has_the_resolved_sizes_height() {
let (config, metrics, layout) = password_field();
let rect = caret_rect(
&layout,
config.plate,
&config,
&metrics,
(0.0, 0.0),
end_place(&layout),
0.4,
);
assert!((rect.x0 - 199.0).abs() < 1e-3, "{rect:?}");
assert!((rect.x1 - rect.x0 - 0.4).abs() < 1e-4, "{rect:?}");
assert!((rect.y1 - rect.y0 - 27.9).abs() < 1e-3, "{rect:?}");
let missing = caret_rect(
&layout,
config.plate,
&config,
&metrics,
(0.0, 0.0),
Place::new(0, 7, Some(0)),
0.4,
);
assert!((missing.y1 - missing.y0).abs() < 1e-6, "{missing:?}");
}
#[test]
fn a_click_in_an_auto_sized_field_uses_the_resolved_midpoints() {
let (config, metrics, layout) = password_field();
let advance = 389.0 * 25.0 / 1000.0;
let first = f64::from(150.375_f32);
for index in 0..5 {
let x = first + advance * (f64::from(index) + 0.5) + 0.01;
let place = place_at_point(
&layout,
config.plate,
&config,
&metrics,
(0.0, 0.0),
Point::new(x, 175.0),
);
assert_eq!(place.word, Some(index), "click at {x} landed at {place:?}");
}
let header = place_at_point(
&layout,
config.plate,
&config,
&metrics,
(0.0, 0.0),
Point::new(first + advance * 0.5 - 0.01, 175.0),
);
assert_eq!(header.word, None, "{header:?}");
}
#[test]
fn an_explicitly_sized_field_answers_the_same_as_it_always_did() {
let config = config();
let metrics = metrics();
let layout = vt::layout("ABC", &config, &metrics);
assert!((layout.font_size - config.font_size).abs() < f32::EPSILON);
let caret = point_at_place(
&layout,
config.plate,
&config,
&metrics,
(0.0, 0.0),
Place::new(0, 0, Some(0)),
)
.x;
assert!(
(caret - (101.0 + 667.0 * 12.0 / 1000.0)).abs() < 1e-3,
"{caret}"
);
}
}