Skip to content

Commit

Permalink
Create CursorTest harness
Browse files Browse the repository at this point in the history
  • Loading branch information
PoignardAzur committed Dec 11, 2024
1 parent 1a8740d commit 65dc7b4
Show file tree
Hide file tree
Showing 3 changed files with 281 additions and 0 deletions.
267 changes: 267 additions & 0 deletions parley/src/cursor_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
// Copyright 2024 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT

use crate::{Affinity, Cursor, FontContext, FontStack, Layout, LayoutContext};

/// Helper struct for creating cursors and checking their values.
///
/// This type implements multiple assertion methods which, on failure, will
/// print the input text with cursor's expected and actual positions highlighted.
/// This should make test failures more readable than printing the cursor's byte index.
///
/// The following are not supported:
///
/// - RTL text.
/// - Multi-line text.
/// - Any character that doesn't span a single terminal tile.
/// - Multi-bytes characters.
///
/// Some of these limitations are inherent to visually displaying a text layout in the
/// terminal.
///
/// Others will be fixed in the future.
pub struct CursorTest {
text: String,
layout: Layout<()>,
}

impl CursorTest {
pub fn single_line(text: &str, lcx: &mut LayoutContext<()>, fcx: &mut FontContext) -> Self {
let mut builder = lcx.ranged_builder(fcx, text, 1.0);
builder.push(
FontStack::Single(crate::FontFamily::Generic(
fontique::GenericFamily::Monospace,
)),
..,
);
let mut layout = builder.build(text);
layout.break_all_lines(None);

Self {
text: text.to_string(),
layout,
}
}

/// Returns the text that was used to create the layout.
pub fn text(&self) -> &str {
&self.text
}

/// Returns the layout that was created from the text.
pub fn layout(&self) -> &Layout<()> {
&self.layout
}

#[track_caller]
fn get_unique_index(&self, method_name: &str, needle: &str) -> usize {
let Some(index) = self.text.find(needle) else {
panic!(
"Error in {method_name}: needle '{needle}' not found in text '{}'",
self.text
);
};
dbg!(index);
if self.text[index + needle.len()..].find(needle).is_some() {
panic!(
"Error in {method_name}: needle '{needle}' found multiple times in text '{}'",
self.text
);
}
index
}

/// Returns a cursor that points to the first character of the needle, with
/// [`Affinity::Downstream`].
///
/// The needle must be unique in the text to avoid ambiguity.
///
/// ### Panics
///
/// - If the needle is not found in the text.
/// - If the needle is found multiple times in the text.
#[track_caller]
pub fn cursor_before(&self, needle: &str) -> Cursor {
let index = self.get_unique_index("cursor_before", needle);
Cursor::from_byte_index(&self.layout, index, Affinity::Downstream)
}

/// Returns a cursor that points to the first character after the needle, with
/// [`Affinity::Upstream`].
///
/// The needle must be unique in the text to avoid ambiguity.
///
/// ### Panics
///
/// - If the needle is not found in the text.
/// - If the needle is found multiple times in the text.
#[track_caller]
pub fn cursor_after(&self, needle: &str) -> Cursor {
let index = self.get_unique_index("cursor_after", needle);
let index = index + needle.len();
Cursor::from_byte_index(&self.layout, index, Affinity::Upstream)
}

fn cursor_to_monospace(&self, cursor: Cursor, is_correct: bool) -> String {
fn check_no_color() -> bool {
let Some(env_var) = std::env::var_os("NO_COLOR") else {
return false;
};
let env_var = env_var.to_str().unwrap_or_default().trim();

if env_var == "0" {
return false;
}
if env_var.to_ascii_lowercase() == "false" {
return false;
}
true
}

// NOTE: The background color doesn't carry important information,
// so we do a simple implementation, without worrying about
// color-blindness and platform issues.
let ansi_bg_color = if cfg!(not(unix)) || check_no_color() {
""
} else if is_correct {
// Green background
"\x1b[48;5;70m"
} else {
// Red background
"\x1b[48;5;160m"
};
let ansi_reset = if cfg!(not(unix)) { "" } else { "\x1b[0m" };
let index = cursor.index();
let affinity = cursor.affinity();

let cursor_str = if affinity == Affinity::Upstream {
// - ANSI code for 'Set background color'
// - Unicode sequence for '▕' character
// - ANSI code for 'Reset all attributes'
format!("{ansi_bg_color}\u{2595}{ansi_reset}")
} else {
// - 1 space
// - ANSI code for 'Set background color'
// - Unicode sequence for '▏' character
// - ANSI code for 'Reset all attributes'
format!(" {ansi_bg_color}\u{258F}{ansi_reset}")
};

" ".repeat(index) + &cursor_str
}

#[track_caller]
fn cursor_assertion(&self, expected: Cursor, actual: Cursor) {
if expected == actual {
return;
}

// TODO - Check that the tested string doesn't include difficult
// characters (newlines, tabs, RTL text, etc.)
// If it does, we should still print the text on a best effort basis, but
// without visual cursors and with a warning that the text may not be accurate.

panic!(
concat!(
"cursor assertion failed\n",
" expected: '{text}' - ({expected_index}, {expected_affinity})\n",
" {expected_cursor}\n",
" got: '{text}' - ({actual_index}, {actual_affinity})\n",
" {actual_cursor}\n",
),
text = self.text,
expected_index = expected.index(),
expected_affinity = expected.affinity(),
actual_index = actual.index(),
actual_affinity = actual.affinity(),
expected_cursor = self.cursor_to_monospace(expected, true),
actual_cursor = self.cursor_to_monospace(actual, false),
);
}

/// Asserts that the cursor is before the needle.
///
/// The needle must be unique in the text to avoid ambiguity.
///
/// ### Panics
///
/// - If the needle is not found in the text.
/// - If the needle is found multiple times in the text.
/// - If the cursor has the wrong position.
/// - If the cursor doesn't have [`Affinity::Downstream`].
#[track_caller]
pub fn assert_cursor_is_before(&self, needle: &str, cursor: Cursor) {
let index = self.get_unique_index("assert_cursor_is_before", needle);

let expected_cursor = Cursor::from_byte_index(&self.layout, index, Affinity::Downstream);
self.cursor_assertion(expected_cursor, cursor);
}

/// Asserts that the cursor is after the needle.
///
/// The needle must be unique in the text to avoid ambiguity.
///
/// ### Panics
///
/// - If the needle is not found in the text.
/// - If the needle is found multiple times in the text.
/// - If the cursor has the wrong position.
/// - If the cursor doesn't have [`Affinity::Upstream`].
#[track_caller]
pub fn assert_cursor_is_after(&self, needle: &str, cursor: Cursor) {
let index = self.get_unique_index("assert_cursor_is_after", needle);
let index = index + needle.len();

let expected_cursor = Cursor::from_byte_index(&self.layout, index, Affinity::Upstream);
self.cursor_assertion(expected_cursor, cursor);
}

/// Compares two cursors and asserts that they are the same.
///
/// ### Panics
///
/// - If the cursors don't have the same index.
/// - If the cursors don't have the same affinity.
#[track_caller]
pub fn assert_cursor_is(&self, expected: Cursor, cursor: Cursor) {
self.cursor_assertion(expected, cursor);
}

/// Prints the TestLayout's text, with the cursor highlighted.
///
/// Uses the same format as assertion failures.
#[track_caller]
pub fn print_cursor(&self, cursor: Cursor) {
eprintln!(
concat!(
"dumping test layout value\n",
"text: '{text}' - ({actual_index}, {actual_affinity})\n",
" {actual_cursor}\n",
),
text = self.text,
actual_index = cursor.index(),
actual_affinity = cursor.affinity(),
actual_cursor = self.cursor_to_monospace(cursor, true),
);
}
}

// ---

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn cursor_next_visual() {
let (mut lcx, mut fcx) = (LayoutContext::new(), FontContext::new());
let text = "Lorem ipsum dolor sit amet";
let layout = CursorTest::single_line(text, &mut lcx, &mut fcx);

let mut cursor: Cursor = layout.cursor_before("dolor");
layout.print_cursor(cursor);
cursor = cursor.next_visual(&layout.layout);

layout.assert_cursor_is_after("ipsum d", cursor);
}
}
12 changes: 12 additions & 0 deletions parley/src/layout/cluster.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT

use core::fmt::Display;

use super::{BreakReason, Brush, Cluster, ClusterInfo, Glyph, Layout, Line, Range, Run};
use swash::text::cluster::Whitespace;

Expand Down Expand Up @@ -394,6 +396,16 @@ impl Affinity {
}
}

impl Display for Affinity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = match self {
Self::Downstream => "Downstream",
Self::Upstream => "Upstream",
};
write!(f, "{}", name)
}
}

/// Index based path to a cluster.
#[derive(Copy, Clone, PartialEq, Eq, Default, Debug)]
pub struct ClusterPath {
Expand Down
2 changes: 2 additions & 0 deletions parley/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ mod util;
pub mod layout;
pub mod style;

mod cursor_test;
#[cfg(test)]
mod tests;

Expand All @@ -124,6 +125,7 @@ pub use peniko::Font;

pub use builder::{RangedBuilder, TreeBuilder};
pub use context::LayoutContext;
pub use cursor_test::CursorTest;
pub use font::FontContext;
pub use inline_box::InlineBox;
#[doc(inline)]
Expand Down

0 comments on commit 65dc7b4

Please sign in to comment.