avm1: implement scroll, maxscroll and bottomScroll for TextField (#4698)
* tests: add tests for scroll * avm1: implement scroll, maxscroll, bottomScroll * chore: fmt * docs: note that scroll is 1-based * fix: non-word wrapped text with manual breaks is scrollable * chore: move magic number to const * chore: avoid mut with extra if * chore: moving clamping behaviour into core * refactor: eagerly compute line data * fix: make scroll work when text is aligned right * chore: clippy * docs: add more information about line_data * tests: add more test cases for scroll
This commit is contained in:
parent
d334e30259
commit
ac0fc40345
|
@ -78,6 +78,9 @@ const PROTO_DECLS: &[Declaration] = declare_properties! {
|
|||
"password" => property(tf_getter!(password), tf_setter!(set_password));
|
||||
"hscroll" => property(tf_getter!(hscroll), tf_setter!(set_hscroll));
|
||||
"maxhscroll" => property(tf_getter!(maxhscroll));
|
||||
"scroll" => property(tf_getter!(scroll), tf_setter!(set_scroll));
|
||||
"maxscroll" => property(tf_getter!(maxscroll));
|
||||
"bottomScroll" => property(tf_getter!(bottom_scroll));
|
||||
};
|
||||
|
||||
/// Implements `TextField`
|
||||
|
@ -617,3 +620,34 @@ pub fn maxhscroll<'gc>(
|
|||
) -> Result<Value<'gc>, Error<'gc>> {
|
||||
Ok(this.maxhscroll().into())
|
||||
}
|
||||
|
||||
pub fn scroll<'gc>(
|
||||
this: EditText<'gc>,
|
||||
_activation: &mut Activation<'_, 'gc, '_>,
|
||||
) -> Result<Value<'gc>, Error<'gc>> {
|
||||
Ok(this.scroll().into())
|
||||
}
|
||||
|
||||
pub fn set_scroll<'gc>(
|
||||
this: EditText<'gc>,
|
||||
activation: &mut Activation<'_, 'gc, '_>,
|
||||
value: Value<'gc>,
|
||||
) -> Result<(), Error<'gc>> {
|
||||
let input = value.coerce_to_f64(activation)?;
|
||||
this.set_scroll(input, &mut activation.context);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn maxscroll<'gc>(
|
||||
this: EditText<'gc>,
|
||||
_activation: &mut Activation<'_, 'gc, '_>,
|
||||
) -> Result<Value<'gc>, Error<'gc>> {
|
||||
Ok(this.maxscroll().into())
|
||||
}
|
||||
|
||||
pub fn bottom_scroll<'gc>(
|
||||
this: EditText<'gc>,
|
||||
_activation: &mut Activation<'_, 'gc, '_>,
|
||||
) -> Result<Value<'gc>, Error<'gc>> {
|
||||
Ok(this.bottom_scroll().into())
|
||||
}
|
||||
|
|
|
@ -153,8 +153,62 @@ pub struct EditTextData<'gc> {
|
|||
/// Which rendering engine this text field will use.
|
||||
render_settings: TextRenderSettings,
|
||||
|
||||
/// How many pixels right the text is offset by
|
||||
/// How many pixels right the text is offset by. 0-based index.
|
||||
hscroll: f64,
|
||||
|
||||
/// Information about the layout's current lines. Used by scroll properties.
|
||||
line_data: Vec<LineData>,
|
||||
|
||||
/// How many lines down the text is offset by. 1-based index.
|
||||
scroll: usize,
|
||||
}
|
||||
|
||||
// TODO: would be nicer to compute (and return) this during layout, instead of afterwards
|
||||
/// Compute line (index, offset, extent) from the layout data.
|
||||
fn get_line_data(layout: &[LayoutBox]) -> Vec<LineData> {
|
||||
// if there are no boxes, there are no lines
|
||||
if layout.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let first_box = &layout[0];
|
||||
|
||||
let mut index = 1;
|
||||
let mut offset = first_box.bounds().offset_y();
|
||||
let mut extent = first_box.bounds().extent_y();
|
||||
|
||||
let mut line_data = Vec::new();
|
||||
|
||||
for layout_box in layout.get(1..).unwrap() {
|
||||
let bounds = layout_box.bounds();
|
||||
|
||||
// if the top of the new box is lower than the bottom of the old box, it's a new line
|
||||
if bounds.offset_y() > extent {
|
||||
// save old line and reset
|
||||
line_data.push(LineData {
|
||||
index,
|
||||
offset,
|
||||
extent,
|
||||
});
|
||||
|
||||
index += 1;
|
||||
offset = bounds.offset_y();
|
||||
extent = bounds.extent_y();
|
||||
} else {
|
||||
// otherwise we continue from the previous box
|
||||
offset = offset.min(bounds.offset_y());
|
||||
extent = extent.max(bounds.extent_y());
|
||||
}
|
||||
}
|
||||
|
||||
// save the final line
|
||||
line_data.push(LineData {
|
||||
index,
|
||||
offset,
|
||||
extent,
|
||||
});
|
||||
|
||||
line_data
|
||||
}
|
||||
|
||||
impl<'gc> EditText<'gc> {
|
||||
|
@ -207,6 +261,7 @@ impl<'gc> EditText<'gc> {
|
|||
swf_tag.is_word_wrap,
|
||||
swf_tag.is_device_font,
|
||||
);
|
||||
let line_data = get_line_data(&layout);
|
||||
|
||||
let has_background = swf_tag.has_border;
|
||||
let background_color = 0xFFFFFF; // Default is white
|
||||
|
@ -285,6 +340,8 @@ impl<'gc> EditText<'gc> {
|
|||
has_focus: false,
|
||||
render_settings: Default::default(),
|
||||
hscroll: 0.0,
|
||||
line_data,
|
||||
scroll: 1,
|
||||
},
|
||||
));
|
||||
|
||||
|
@ -770,10 +827,12 @@ impl<'gc> EditText<'gc> {
|
|||
edit_text.is_device_font,
|
||||
);
|
||||
|
||||
edit_text.line_data = get_line_data(&new_layout);
|
||||
edit_text.layout = new_layout;
|
||||
edit_text.intrinsic_bounds = intrinsic_bounds;
|
||||
// reset scroll
|
||||
edit_text.hscroll = 0.0;
|
||||
edit_text.scroll = 1;
|
||||
|
||||
match autosize {
|
||||
AutoSizeMode::None => {}
|
||||
|
@ -850,6 +909,53 @@ impl<'gc> EditText<'gc> {
|
|||
}
|
||||
}
|
||||
|
||||
/// How many lines the text can be scrolled down
|
||||
pub fn maxscroll(self) -> usize {
|
||||
let edit_text = self.0.read();
|
||||
|
||||
let line_data = &edit_text.line_data;
|
||||
|
||||
if line_data.is_empty() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
let target = line_data.last().unwrap().extent - edit_text.bounds.height();
|
||||
|
||||
// minimum line n such that n.offset > max.extent - bounds.height()
|
||||
let max_line = line_data.iter().find(|&&l| target < l.offset);
|
||||
if let Some(line) = max_line {
|
||||
line.index
|
||||
} else {
|
||||
// I don't know how this could happen, so return the limit
|
||||
line_data.last().unwrap().index
|
||||
}
|
||||
}
|
||||
|
||||
/// The lowest visible line of text
|
||||
pub fn bottom_scroll(self) -> usize {
|
||||
let edit_text = self.0.read();
|
||||
|
||||
let line_data = &edit_text.line_data;
|
||||
|
||||
if line_data.is_empty() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
let scroll_offset = line_data
|
||||
.get(edit_text.scroll - 1)
|
||||
.map_or(Twips::ZERO, |l| l.offset);
|
||||
let target = edit_text.bounds.height() + scroll_offset;
|
||||
|
||||
// Line before first line with extent greater than bounds.height() + line "scroll"'s offset
|
||||
let too_far = line_data.iter().find(|&&l| l.extent > target);
|
||||
if let Some(line) = too_far {
|
||||
line.index - 1
|
||||
} else {
|
||||
// all lines are visible
|
||||
line_data.last().unwrap().index
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a layout box, plus its children.
|
||||
fn render_layout_box(self, context: &mut RenderContext<'_, 'gc>, lbox: &LayoutBox<'gc>) {
|
||||
let box_transform: Transform = lbox.bounds().origin().into();
|
||||
|
@ -1131,6 +1237,23 @@ impl<'gc> EditText<'gc> {
|
|||
self.0.write(context.gc_context).hscroll = hscroll;
|
||||
}
|
||||
|
||||
pub fn scroll(self) -> usize {
|
||||
self.0.read().scroll
|
||||
}
|
||||
|
||||
pub fn set_scroll(self, scroll: f64, context: &mut UpdateContext<'_, 'gc, '_>) {
|
||||
// derived experimentally. Not exact: overflows somewhere above 767100486418432.9
|
||||
// Checked in SWF 6, AVM1. Same in AVM2.
|
||||
const SCROLL_OVERFLOW_LIMIT: f64 = 767100486418433.0;
|
||||
let scroll_lines = if scroll.is_nan() || scroll < 0.0 || scroll >= SCROLL_OVERFLOW_LIMIT {
|
||||
1
|
||||
} else {
|
||||
scroll as usize
|
||||
};
|
||||
let clamped = scroll_lines.clamp(1, self.maxscroll());
|
||||
self.0.write(context.gc_context).scroll = clamped;
|
||||
}
|
||||
|
||||
pub fn screen_position_to_index(self, position: (Twips, Twips)) -> Option<usize> {
|
||||
let text = self.0.read();
|
||||
let position = self.global_to_local(position);
|
||||
|
@ -1598,13 +1721,24 @@ impl<'gc> TDisplayObject<'gc> for EditText<'gc> {
|
|||
);
|
||||
context.renderer.activate_mask();
|
||||
|
||||
let scroll_offset = if edit_text.scroll > 1 {
|
||||
let line_data = &edit_text.line_data;
|
||||
|
||||
if let Some(line_data) = line_data.get(edit_text.scroll - 1) {
|
||||
line_data.offset
|
||||
} else {
|
||||
Twips::ZERO
|
||||
}
|
||||
} else {
|
||||
Twips::ZERO
|
||||
};
|
||||
// TODO: Where does this come from? How is this different than INTERNAL_PADDING? Does this apply to y as well?
|
||||
// If this is actually right, offset the border in `redraw_border` instead of doing an extra push.
|
||||
context.transform_stack.push(&Transform {
|
||||
matrix: Matrix {
|
||||
tx: Twips::from_pixels(Self::INTERNAL_PADDING)
|
||||
- Twips::from_pixels(edit_text.hscroll),
|
||||
ty: Twips::from_pixels(Self::INTERNAL_PADDING),
|
||||
ty: Twips::from_pixels(Self::INTERNAL_PADDING) - scroll_offset,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
|
@ -1785,6 +1919,17 @@ pub struct TextSelection {
|
|||
to: usize,
|
||||
}
|
||||
|
||||
/// Information about the start and end y-coordinates of a given line of text
|
||||
#[derive(Copy, Clone, Debug, Collect)]
|
||||
#[collect(require_static)]
|
||||
pub struct LineData {
|
||||
index: usize,
|
||||
/// How many twips down the highest point of the line is
|
||||
offset: Twips,
|
||||
/// How many twips down the lowest point of the line is
|
||||
extent: Twips,
|
||||
}
|
||||
|
||||
impl TextSelection {
|
||||
pub fn for_position(position: usize) -> Self {
|
||||
Self {
|
||||
|
|
|
@ -282,6 +282,7 @@ swf_tests! {
|
|||
#[ignore] (edittext_newlines, "avm1/edittext_newlines", 1),
|
||||
(edittext_html_entity, "avm1/edittext_html_entity", 1),
|
||||
(edittext_password, "avm1/edittext_password", 1),
|
||||
(edittext_scroll, "avm1/edittext_scroll", 1),
|
||||
#[ignore] (edittext_html_roundtrip, "avm1/edittext_html_roundtrip", 1),
|
||||
(edittext_newline_stripping, "avm1/edittext_newline_stripping", 1),
|
||||
(define_local, "avm1/define_local", 1),
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
// text field with width 100
|
||||
// text: A test for scroll
|
||||
// title_txt.maxscroll
|
||||
1
|
||||
// title_txt.bottomScroll
|
||||
1
|
||||
// title_txt.scroll = -10
|
||||
1
|
||||
// title_txt.scroll = 10
|
||||
1
|
||||
// text: A longer test for scroll. This entry has more than one line, so we must scroll to see more.
|
||||
// title_txt.maxscroll
|
||||
5
|
||||
// title_txt.bottomScroll
|
||||
2
|
||||
// title_txt.scroll = -10
|
||||
1
|
||||
// title_txt.scroll = 2
|
||||
2
|
||||
// title_txt.bottomScroll
|
||||
3
|
||||
// title_txt.scroll = 100
|
||||
5
|
||||
// title_txt.bottomScroll
|
||||
6
|
||||
// title_txt.scroll = null
|
||||
1
|
||||
// title_txt.scroll = undefined
|
||||
1
|
||||
// title_txt.scroll = 1.5
|
||||
1
|
||||
// title_txt.scroll = "4"
|
||||
4
|
||||
// title_txt.scroll = 2147483647
|
||||
5
|
||||
// title_txt.scroll = 767100486418432
|
||||
5
|
||||
// title_txt.scroll = 767100486418432.9
|
||||
5
|
||||
// title_txt.scroll = 767100486418433
|
||||
1
|
||||
|
||||
// right-aligned text box
|
||||
// right_text.text = A longer test for scroll. This entry has more than one line, so we must scroll to see more.
|
||||
// right_text.maxscroll
|
||||
5
|
||||
// right_text.bottomScroll
|
||||
2
|
||||
|
||||
// text box with multiple font combinations
|
||||
// funny_fonts.maxscroll
|
||||
3
|
||||
// funny_fonts.bottomScroll
|
||||
2
|
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue