445 lines
16 KiB
Rust
445 lines
16 KiB
Rust
use float_cmp::ApproxEq;
|
|
use sum_tree::{SeekBias, SumTree};
|
|
use galaxyui::{
|
|
SizeConstraint,
|
|
geometry::{
|
|
rect::RectF,
|
|
vector::{Vector2F, vec2f},
|
|
},
|
|
units::{IntoPixels, Pixels},
|
|
};
|
|
|
|
use crate::render::element::RenderContext;
|
|
use string_offset::CharOffset;
|
|
|
|
use super::{
|
|
AUTO_SCROLL_MARGIN, BlockItem, BlockSpacing, Height, HitTestOptions, LayoutSummary, Location,
|
|
RenderState, UNIT_MARGIN, bounds, positioned::PositionedCursor,
|
|
};
|
|
|
|
/// For horizontal autoscrolling, it is very easy to "stuck" on a character if it is aligned exactly on the viewport boundary.
|
|
/// To help make scrolling more smooth, add a small margin here to overcome these boundaries.
|
|
const HORIZONTAL_SCROLL_MARGIN: f32 = 4.;
|
|
|
|
#[cfg(test)]
|
|
#[path = "viewport_tests.rs"]
|
|
mod tests;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct ViewportState {
|
|
/// Width of the viewport. Currently, we soft-wrap text to fit this. However,
|
|
/// we'll eventually support horizontal scrolling if the viewport is narrower
|
|
/// than some minimum content width.
|
|
width: Pixels,
|
|
/// Height of the viewport. All scrolling and viewporting is in terms of
|
|
/// pixels, not lines, as the line height varies for different content.
|
|
height: Pixels,
|
|
|
|
/// Vertical scrolling offset. This is the distance from the start of the
|
|
/// content (height 0) to the first visible content.
|
|
scroll_top: Pixels,
|
|
|
|
/// Horizontal scrolling offset.
|
|
scroll_left: Pixels,
|
|
}
|
|
|
|
/// A visible, viewported item. This stores all the information needed to lay out and display a
|
|
/// block and any associated UI controls in the current viewport.
|
|
///
|
|
/// Because the viewport item is needed throughout the `Element` lifecycle, it does not directly
|
|
/// reference the rendering model. Instead, it holds offsets that refer back to the model, relying
|
|
/// on the UI framework to guarantee that the model does not change without a re-render.
|
|
#[derive(Debug)]
|
|
pub struct ViewportItem {
|
|
/// The y-offset to display this item at, relative to the viewport origin.
|
|
/// If this is negative, the item is partially above the viewport.
|
|
pub viewport_offset: Pixels,
|
|
/// The y-offset of this item, relative to the content origin.
|
|
pub content_offset: Pixels,
|
|
/// The size of this item's content, in pixels.
|
|
pub content_size: Vector2F,
|
|
/// Spacing around this item.
|
|
pub spacing: BlockSpacing,
|
|
/// Offset of the start of the block backing this item.
|
|
pub block_offset: CharOffset,
|
|
}
|
|
|
|
/// A snapshot of the scroll position. This may only be used to scroll back to the original
|
|
/// position, and cannot be inspected.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct ScrollPositionSnapshot {
|
|
/// The offset of the top left character in the viewport. We use this to represent the scroll
|
|
/// position, rather than a line count, to be resilient to soft-wrapping changes. If the
|
|
/// viewport is resized, then the content that a given line offset refers to will likely be
|
|
/// different.
|
|
first_character_offset: CharOffset,
|
|
}
|
|
|
|
impl ScrollPositionSnapshot {
|
|
/// Map this snapshot back to a `scroll_top` offset for the current render state.
|
|
pub(super) fn to_scroll_top(self, render_state: &RenderState) -> Pixels {
|
|
render_state
|
|
.character_bounds(self.first_character_offset)
|
|
.map_or(Pixels::zero(), |bounds| bounds.min_y().into_pixels())
|
|
}
|
|
|
|
/// Snapshot the render state's current scroll position.
|
|
pub(super) fn from_scroll_top(render_state: &RenderState) -> Self {
|
|
let first_character_offset = match render_state.viewport_coordinates_to_location(
|
|
Pixels::zero(),
|
|
Pixels::zero(),
|
|
&HitTestOptions {
|
|
force_text_selection: true,
|
|
},
|
|
) {
|
|
Location::Text { char_offset, .. } => char_offset,
|
|
Location::Block { start_offset, .. } => start_offset,
|
|
};
|
|
Self {
|
|
first_character_offset,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn first_character_offset(self) -> CharOffset {
|
|
self.first_character_offset
|
|
}
|
|
}
|
|
|
|
pub struct ViewportIterator<'a> {
|
|
cursor: sum_tree::Cursor<'a, BlockItem, Height, LayoutSummary>,
|
|
/// The starting y-offset of content to display.
|
|
content_start: Pixels,
|
|
/// The ending y-offset of content to display (exclusive). This may be past
|
|
/// the end of the document, but it just needs to be an upper bound.
|
|
content_end: Pixels,
|
|
/// Maximum width the painted object could take in the current viewport.
|
|
max_width: Pixels,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct SizeInfo {
|
|
/// The size of the viewport, in pixels.
|
|
pub viewport_size: Vector2F,
|
|
|
|
/// Whether or not text must be laid out again to fit the new viewport size.
|
|
pub needs_layout: bool,
|
|
}
|
|
|
|
impl ViewportState {
|
|
/// Create a new `ViewportState` with the given viewport size, scrolled to
|
|
/// the top of the document.
|
|
pub fn new(width: Pixels, height: Pixels) -> Self {
|
|
Self {
|
|
width,
|
|
height,
|
|
scroll_top: Pixels::zero(),
|
|
scroll_left: Pixels::zero(),
|
|
}
|
|
}
|
|
|
|
/// Width of the viewport. When rendering, it's assumed that the UI
|
|
/// element is this wide.
|
|
pub fn width(&self) -> Pixels {
|
|
self.width
|
|
}
|
|
|
|
/// Height of the viewport. When rendering, it's assumed that the UI element
|
|
/// is this tall.
|
|
pub fn height(&self) -> Pixels {
|
|
self.height
|
|
}
|
|
|
|
/// The current vertical scroll position of the viewport.
|
|
pub fn scroll_top(&self) -> Pixels {
|
|
self.scroll_top
|
|
}
|
|
|
|
/// The current horizontal scroll position of the viewport.
|
|
pub fn scroll_left(&self) -> Pixels {
|
|
self.scroll_left
|
|
}
|
|
|
|
/// Vertically scroll by `delta` pixels. Scrolling is capped at `content_height`,
|
|
/// which should be the height of the buffer content.
|
|
///
|
|
/// Returns whether the view should be re-rendered.
|
|
pub(super) fn scroll(&mut self, delta: Pixels, content_height: Pixels) -> bool {
|
|
self.scroll_to(self.scroll_top - delta, content_height)
|
|
}
|
|
|
|
pub(super) fn scroll_horizontally(&mut self, delta: Pixels, content_width: Pixels) -> bool {
|
|
self.scroll_horizontally_to(self.scroll_left - delta, content_width)
|
|
}
|
|
|
|
/// Scroll to the given `scroll_top`, clamped to the end of the buffer.
|
|
///
|
|
/// Returns whether or not the view needs to be re-rendered.
|
|
pub(super) fn scroll_to(&mut self, scroll_top: Pixels, content_height: Pixels) -> bool {
|
|
let scroll_top = self.clamp_scroll_offset(scroll_top, content_height, self.height);
|
|
let changed = scroll_top.approx_ne(self.scroll_top, UNIT_MARGIN);
|
|
if changed {
|
|
self.scroll_top = scroll_top;
|
|
}
|
|
changed
|
|
}
|
|
|
|
pub(super) fn scroll_horizontally_to(
|
|
&mut self,
|
|
scroll_left: Pixels,
|
|
content_width: Pixels,
|
|
) -> bool {
|
|
let scroll_left = self.clamp_scroll_offset(scroll_left, content_width, self.width);
|
|
let changed = scroll_left.approx_ne(self.scroll_left, UNIT_MARGIN);
|
|
if changed {
|
|
self.scroll_left = scroll_left;
|
|
}
|
|
changed
|
|
}
|
|
|
|
/// Set the scroll position to an exact location.
|
|
#[cfg(test)]
|
|
pub(super) fn set_scroll_top(&mut self, scroll_top: Pixels) {
|
|
self.scroll_top = scroll_top;
|
|
}
|
|
|
|
/// Notifies the viewport model that the content height has changed, which
|
|
/// affects the range of valid scroll positions.
|
|
///
|
|
/// Returns whether the view should be re-rendered.
|
|
pub(super) fn update_content_height(&mut self, content_height: Pixels) -> bool {
|
|
// A scroll of 0 will reapply the clamping logic to ensure the scroll
|
|
// position is still in bounds.
|
|
self.scroll(Pixels::zero(), content_height)
|
|
}
|
|
|
|
pub(super) fn update_content_width(&mut self, content_width: Pixels) -> bool {
|
|
// A scroll of 0 will reapply the clamping logic to ensure the scroll
|
|
// position is still in bounds.
|
|
self.scroll_horizontally(Pixels::zero(), content_width)
|
|
}
|
|
|
|
pub(super) fn autoscroll(
|
|
&mut self,
|
|
item_start: Vector2F,
|
|
item_end: Vector2F,
|
|
content_height: Pixels,
|
|
content_width: Pixels,
|
|
should_autoscroll_horizontally: bool,
|
|
) -> bool {
|
|
let mut changed = false;
|
|
|
|
if should_autoscroll_horizontally {
|
|
if (item_start.x() - HORIZONTAL_SCROLL_MARGIN).into_pixels() < self.scroll_left {
|
|
changed = self.scroll_horizontally(
|
|
self.scroll_left - item_start.x().into_pixels()
|
|
+ AUTO_SCROLL_MARGIN.into_pixels(),
|
|
content_width,
|
|
) || changed;
|
|
} else if (item_end.x() + HORIZONTAL_SCROLL_MARGIN).into_pixels()
|
|
> self.scroll_left + self.width
|
|
{
|
|
changed = self.scroll_horizontally(
|
|
self.scroll_left - item_end.x().into_pixels() + self.width
|
|
- AUTO_SCROLL_MARGIN.into_pixels(),
|
|
content_width,
|
|
) || changed;
|
|
}
|
|
}
|
|
|
|
if item_start.y().into_pixels() < self.scroll_top {
|
|
// The position we want to scroll to is `item_start - AUTO_SCROLL_MARGIN.into_pixels()`.
|
|
changed = self.scroll(
|
|
self.scroll_top - item_start.y().into_pixels() + AUTO_SCROLL_MARGIN.into_pixels(),
|
|
content_height,
|
|
) || changed;
|
|
} else if item_end.y().into_pixels() > self.scroll_top + self.height {
|
|
// The position we want to scroll to is `item_end - self.height + AUTO_SCROLL_MARGIN.into_pixels()`.
|
|
changed = self.scroll(
|
|
self.scroll_top - item_end.y().into_pixels() + self.height
|
|
- AUTO_SCROLL_MARGIN.into_pixels(),
|
|
content_height,
|
|
) || changed;
|
|
}
|
|
|
|
changed
|
|
}
|
|
|
|
/// Clamps a scroll position to a valid value. The scroll top must be positive,
|
|
/// and is at most the content height minus the viewport height. The viewport
|
|
/// is scrolled all the way to the top if the scroll position is 0 and
|
|
/// all the way to the bottom if the last viewport's worth of content is
|
|
/// visible.
|
|
fn clamp_scroll_offset(
|
|
&self,
|
|
scroll_top: Pixels,
|
|
content_height: Pixels,
|
|
viewport_height: Pixels,
|
|
) -> Pixels {
|
|
scroll_top
|
|
.min(content_height - viewport_height)
|
|
.max(Pixels::zero())
|
|
}
|
|
|
|
/// Calculates the viewport size given layout constraints.
|
|
///
|
|
/// Because we do not have mutable model access when laying out UI elements,
|
|
/// size changes are handled in two steps:
|
|
/// 1. [`crate::render::element::RichTextElement`] calls `viewport_size` as
|
|
/// part of its `layout` implementation.
|
|
/// 2. `RichTextElement` then updates the model with the size it computed
|
|
/// in `after_layout`. Since `after_layout` runs before painting and
|
|
/// event handling, the model still has enough information to viewport,
|
|
/// hit-test, and scroll.
|
|
pub(in crate::render) fn viewport_size(
|
|
&self,
|
|
constraint: SizeConstraint,
|
|
size_buffer: Vector2F,
|
|
max_width: Option<Pixels>,
|
|
) -> SizeInfo {
|
|
// TODO(ben): We should have a minimum soft-wrap width. If the constraint's
|
|
// maximum size is below this, we start horizontal scrolling rather
|
|
// than trying to soft-wrap further.
|
|
|
|
let mut max_constraint = constraint.max;
|
|
if let Some(max_width) = max_width {
|
|
max_constraint.set_x(constraint.max.x().min(max_width.as_f32()));
|
|
}
|
|
|
|
let content_constraint = SizeConstraint::new(
|
|
(constraint.min - size_buffer).max(Vector2F::zero()),
|
|
(max_constraint - size_buffer).max(Vector2F::zero()),
|
|
);
|
|
|
|
let width = content_constraint.max.x();
|
|
let height = content_constraint.max.y();
|
|
|
|
let needs_layout = width.approx_ne(self.width.as_f32(), UNIT_MARGIN);
|
|
|
|
SizeInfo {
|
|
viewport_size: vec2f(width, height),
|
|
needs_layout,
|
|
}
|
|
}
|
|
|
|
/// Save the viewport size that was calculated by a call to [`viewport_size`]
|
|
/// during layout. This should only be called by [`crate::render::element::RichTextElement`],
|
|
/// otherwise there's no guarantee that content is soft-wrapped to the correct bounds.
|
|
///
|
|
/// This may also adjust the scroll position, if it's not valid in the new viewport size.
|
|
pub(super) fn set_size(
|
|
&mut self,
|
|
size: Vector2F,
|
|
content_width: Pixels,
|
|
content_height: Pixels,
|
|
) {
|
|
self.width = size.x().into_pixels();
|
|
self.height = size.y().into_pixels();
|
|
// If set_size is called, the view is already being re-rendered, so we can ignore the
|
|
// return value of update_content_height.
|
|
self.update_content_height(content_height);
|
|
self.update_content_width(content_width);
|
|
}
|
|
}
|
|
|
|
impl<'a> ViewportIterator<'a> {
|
|
/// Begin an iterator over the current viewport.
|
|
pub(super) fn new(
|
|
content: &'a SumTree<BlockItem>,
|
|
scroll_top: Pixels,
|
|
viewport_height: Pixels,
|
|
viewport_width: Pixels,
|
|
) -> Self {
|
|
let mut cursor = content.cursor();
|
|
cursor.seek_clamped(&scroll_top.into(), SeekBias::Left);
|
|
|
|
Self {
|
|
cursor,
|
|
content_start: scroll_top,
|
|
content_end: scroll_top + viewport_height,
|
|
max_width: viewport_width,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'a> Iterator for ViewportIterator<'a> {
|
|
type Item = (ViewportItem, &'a BlockItem);
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
let item = self.cursor.positioned_item()?;
|
|
// Stop rendering once the current item is completely outside the viewport.
|
|
if item.start_y_offset > self.content_end {
|
|
return None;
|
|
}
|
|
self.cursor.next();
|
|
|
|
let spacing = item.item.spacing();
|
|
let content_width = self.max_width - spacing.x_axis_offset();
|
|
let viewport_item = ViewportItem {
|
|
viewport_offset: item.start_y_offset - self.content_start,
|
|
content_offset: item.start_y_offset,
|
|
content_size: vec2f(content_width.as_f32(), item.item.content_height().as_f32()),
|
|
spacing,
|
|
block_offset: item.start_char_offset,
|
|
};
|
|
Some((viewport_item, item.item))
|
|
}
|
|
}
|
|
|
|
impl ViewportItem {
|
|
/// The block backing this viewport item.
|
|
pub fn block_offset(&self) -> CharOffset {
|
|
self.block_offset
|
|
}
|
|
|
|
pub fn height(&self) -> f64 {
|
|
// We sometimes encounter floating point errors when since we are seeking exactly on the edge of
|
|
// a block item. Add a small buffer here so we could consistently seek to the right element.
|
|
self.content_offset.as_f32() as f64 + 0.1
|
|
}
|
|
|
|
/// The content bounds of this item (see [`bounds::content_box`]).
|
|
pub fn content_bounds(&self, ctx: &RenderContext) -> RectF {
|
|
ctx.content_rect_to_screen(bounds::content_box(
|
|
self.content_offset,
|
|
self.content_size,
|
|
&self.spacing,
|
|
))
|
|
}
|
|
|
|
/// The visible bounds of this item (see [`bounds::visible_box`]).
|
|
pub fn visible_bounds(&self, ctx: &RenderContext) -> RectF {
|
|
ctx.content_rect_to_screen(bounds::visible_box(
|
|
self.content_offset,
|
|
self.content_size,
|
|
&self.spacing,
|
|
))
|
|
}
|
|
|
|
/// The reserved bounds of this item (see [`bounds::reserved_box`]).
|
|
pub fn reserved_bounds(&self, ctx: &RenderContext) -> RectF {
|
|
ctx.content_rect_to_screen(bounds::reserved_box(
|
|
self.content_offset,
|
|
self.content_size,
|
|
&self.spacing,
|
|
))
|
|
}
|
|
}
|
|
|
|
#[macro_export]
|
|
macro_rules! extract_block {
|
|
($viewport_item:expr, $content:expr, $match:pat => $value:expr) => {{
|
|
let offset = $viewport_item.block_offset();
|
|
match $content.block_at_offset(offset) {
|
|
Some(block) => match (&block, block.item) {
|
|
$match => $value,
|
|
other => {
|
|
log::trace!("Unexpected block {other:?} at {}", offset);
|
|
return;
|
|
}
|
|
},
|
|
None => return,
|
|
}
|
|
}};
|
|
}
|