Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

avm2: Make TextField.getCharIndexAtPoint accurate #18861

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 4 additions & 16 deletions core/src/avm2/globals/flash/text/text_field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::avm2::{ArrayObject, ArrayStorage, Error};
use crate::display_object::{AutoSizeMode, EditText, TDisplayObject, TextSelection};
use crate::html::TextFormat;
use crate::string::AvmString;
use crate::{avm2_stub_getter, avm2_stub_method, avm2_stub_setter};
use crate::{avm2_stub_getter, avm2_stub_setter};
use swf::{Color, Point};

pub fn text_field_allocator<'gc>(
Expand Down Expand Up @@ -1654,30 +1654,18 @@ pub fn get_char_index_at_point<'gc>(
) -> Result<Value<'gc>, Error<'gc>> {
let this = this.as_object().unwrap();

// TODO This currently uses screen_position_to_index, which is inaccurate, because:
// 1. getCharIndexAtPoint should return -1 when clicked outside of a character,
// 2. screen_position_to_index returns caret index, not clicked character index.
// Currently, it is difficult to prove accuracy of this method, as at the time
// of writing this comment, text layout behaves differently compared to Flash.
// However, the current implementation is good enough to make some SWFs work.
avm2_stub_method!(
activation,
"flash.text.TextField",
"getCharIndexAtPoint",
"inaccurate char index detection"
);

let Some(this) = this
.as_display_object()
.and_then(|this| this.as_edit_text())
else {
return Ok(Value::Undefined);
};

let x = args.get_f64(activation, 0)?;
// No idea why FP does this weird 1px translation...
let x = args.get_f64(activation, 0)? + 1.0;
let y = args.get_f64(activation, 1)?;

if let Some(index) = this.screen_position_to_index(Point::from_pixels(x, y)) {
if let Some(index) = this.char_index_at_point(Point::from_pixels(x, y)) {
Ok(index.into())
} else {
Ok(Value::Number(-1f64))
Expand Down
53 changes: 53 additions & 0 deletions core/src/display_object/edit_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,11 @@ impl<'gc> EditText<'gc> {
self.0.write(context.gc()).max_chars = value;
}

/// Map the position on the screen to caret index.
///
/// This method is used exclusively for placing a caret inside text.
/// It implements the Flash Player's behavior of placing a caret.
/// Characters are divided in half, the last line is extended, etc.
pub fn screen_position_to_index(self, position: Point<Twips>) -> Option<usize> {
let text = self.0.read();
let position = self.global_to_local(position)?;
Expand Down Expand Up @@ -2081,6 +2086,11 @@ impl<'gc> EditText<'gc> {
Some(first_box.start())
}

/// Returns the index of the line that is at the given position.
///
/// It returns `None` when there's no line at the given position,
/// with the exception that positions below the last line will
/// return the index of the last line.
pub fn line_index_at_point(self, position: Point<Twips>) -> Option<usize> {
let edit_text = self.0.read();

Expand All @@ -2100,6 +2110,49 @@ impl<'gc> EditText<'gc> {
)
}

/// Returns the index of the character that is at the given position.
///
/// It returns `None` when there's no character at the given position.
/// It takes into account various quirks of Flash Player:
/// 1. It will return the index of the newline when `x`
/// is zero and the line is empty.
/// 2. It assumes (exclusive, inclusive) bounds.
/// 3. Positions with `y` below the last line will behave
/// the same way as at the last line.
pub fn char_index_at_point(self, position: Point<Twips>) -> Option<usize> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you please add some docstrings to better distinguish this from screen_position_to_index? From just signature and used coordinate space they sound super similar, but name style and implementation are super different.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And now the PR is R4R. (just a gentle ping)

let line_index = self.line_index_at_point(position)?;

let edit_text = self.0.read();
let line = &edit_text.layout.lines()[line_index];

// KJ: It's a bug in FP, it doesn't take into account horizontal
// scroll, but it does take into account vertical scroll.
// See https://github.com/airsdk/Adobe-Runtime-Support/issues/2315
// I guess we'll have to take scrollH into account here when
// we start supporting Harman runtimes.
let x = position.x - Self::GUTTER;

// Yes, this will return the index of the newline when the line is empty.
// Yes, that's how Flash Player does it.
if x == Twips::ZERO {
return Some(line.start());
}

// TODO Use binary search here when possible
for ch in line.start()..line.end() {
let bounds = line.char_x_bounds(ch);
let Some((a, b)) = bounds else {
continue;
};

if a < x && x <= b {
return Some(ch);
}
}

None
}

pub fn line_index_of_char(self, index: usize) -> Option<usize> {
self.0.read().layout.find_line_index_by_position(index)
}
Expand Down
154 changes: 154 additions & 0 deletions tests/tests/swfs/avm2/edittext_get_char_index_at_point/Test.as
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package {
import flash.display.Sprite;
import flash.display.Bitmap;
import flash.display.BitmapData;
import flash.text.TextField;
import flash.text.TextFormat;
import flash.geom.Rectangle;
import flash.utils.ByteArray;

[SWF(width="400", height="400")]
public class Test extends Sprite {
[Embed(source="TestFont.ttf", fontName="TestFont", embedAsCFF="false", unicodeRange="U+0061-U+0064")]
private var TestFont:Class;

private var colors: Array = [
0xFFFF0000,
0xFF00FF00,
0xFF0000FF,
0xFFFFFF00,
0xFF00FFFF,
0xFFFF00FF,
0xFFFFFFFF,
];

private var text1: TextField;

public function Test() {
var t1: TextField = newTextField();
t1.htmlText = "a\n<font size='30'>aa</font>\n<textformat leading='-10'>aaa</textformat>\naa";
renderMap(t1);

var t2: TextField = newTextField(t1);
t2.height = 50;
t2.htmlText = "";
renderMap(t2);

var t3: TextField = newTextField(t2);
t3.height = 50;
t3.type = "input";
t3.htmlText = "";
renderMap(t3);

var t4: TextField = newTextField(t3);
t4.type = "input";
t4.htmlText = "a<font size='25'>a<font size='20'>a<font size='10'>a<font size='20'>a<font size='15'>a</font></font></font></font></font>";
renderMap(t4);

var t5: TextField = newTextField(null, t4);
t5.height = 50;
t5.htmlText = "\n";
renderMap(t5);

var t6: TextField = newTextField(t5, t4);
t6.height = 50;
t6.type = "input";
t6.htmlText = "\n";
renderMap(t6);

var t7: TextField = newTextField(t6, t4);
t7.height = 50;
t7.htmlText = "a\n<textformat leading='-70'>aaa</textformat>\na";
renderMap(t7);

var t8: TextField = newTextField(t7, t4);
t8.htmlText = "\n\na\na";
renderMap(t8);

var t9: TextField = newTextField(null, t8);
t9.height = 60;
t9.htmlText = "\n\n\n\n\n\n\n\n";
t9.scrollV = 3;
renderMap(t9);

var t10: TextField = newTextField(t9, t8);
t10.htmlText = "<textformat leading='0'>a</textformat>\n<textformat leading='5'>a</textformat>\n<textformat leading='15'>a</textformat>\na\n";
renderMap(t10);

var t11: TextField = newTextField(t10, t8);
t11.height = 30;
t11.htmlText = "aaaaaaaaaaaaaaaaaaaaaaaaaa";
t11.scrollH = 50;
trace("scrollh = " + t11.scrollH);
trace("maxscrollh = " + t11.maxScrollH);
renderMap(t11);

var t12: TextField = newTextField(t11, t8);
t12.htmlText = "aaaaaaaaaaaaaaaaaaaaaaaaaa\n\naaaaaaaaaaaaaaaaaaaaaaaaaa";
t12.scrollH = 50;
trace("scrollh = " + t12.scrollH);
trace("maxscrollh = " + t12.maxScrollH);
renderMap(t12);

var t13: TextField = newTextField(t8, t4);
t13.height = 50;
t13.htmlText = "<p align='justify'>a a a aaaa</p>";
t13.wordWrap = true;
renderMap(t13);
}

private function newTextField(lastY: TextField = null, lastX: TextField = null):TextField {
var tf = new TextFormat();
tf.font = "TestFont";
tf.size = 20;
tf.leading = 2;

var field: TextField = new TextField();
field.x = 10;
field.y = 10;
if (lastX != null) {
field.x = lastX.x + lastX.width + 12;
}
if (lastY != null) {
field.y = lastY.y + lastY.height + 12;
}
field.embedFonts = true;
field.border = true;
field.defaultTextFormat = tf;
field.width = 100;
field.height = 100;
return field;
}

private function renderMap(field: TextField, resolution: Number = 2.0):void {
addChild(field);
var w = resolution * (field.width + 10);
var h = resolution * (field.height + 10);
var data:BitmapData = new BitmapData(w, h);
var pixels: ByteArray = new ByteArray();

for (var y = 0; y < h; ++y) {
for (var x = 0; x < w; ++x) {
var ix = field.getCharIndexAtPoint(x / resolution - 5, y / resolution - 5);

var color;
if (ix == -1) {
color = 0xFF000000;
} else {
color = colors[ix % colors.length];
}
pixels.writeUnsignedInt(color);
}
}

pixels.position = 0;
data.setPixels(new Rectangle(0, 0, w, h), pixels);
var bitmap:Bitmap = new Bitmap(data);
bitmap.scaleX = 1 / resolution;
bitmap.scaleY = 1 / resolution;
bitmap.x = field.x - 5;
bitmap.y = field.y - 5;
addChild(bitmap);
}
}
}
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
scrollh = 50
maxscrollh = 320
scrollh = 50
maxscrollh = 320
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
num_ticks = 1

[image_comparisons.output]
tolerance = 0

[player_options]
with_renderer = { optional = false, sample_count = 4 }
viewport_dimensions = { width = 800, height = 800, scale_factor = 1.0 }