5344 lines
198 KiB
Rust
5344 lines
198 KiB
Rust
use core::slice;
|
|
use std::any::Any;
|
|
use std::cell::{Cell, Ref, RefCell};
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::ops::{Add, AddAssign, Range, Sub, SubAssign};
|
|
use std::sync::Arc;
|
|
use std::{fmt, mem};
|
|
|
|
use float_cmp::ApproxEq;
|
|
use itertools::Itertools;
|
|
use markdown_parser::TableAlignment;
|
|
use num_traits::SaturatingSub;
|
|
use ordered_float::OrderedFloat;
|
|
use parking_lot::Mutex;
|
|
use rangemap::RangeSet;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_yaml::Mapping;
|
|
use string_offset::{CharOffset, impl_offset};
|
|
use sum_tree::{SeekBias, SumTree};
|
|
use vec1::Vec1;
|
|
use vim::vim::{MotionType, VimMode};
|
|
use galaxy_core::channel::ChannelState;
|
|
use galaxy_core::ui::Icon;
|
|
use galaxy_core::ui::theme::Fill as ThemeFill;
|
|
use galaxyui_core::assets::asset_cache::AssetSource;
|
|
use galaxyui_core::color::ColorU;
|
|
use galaxyui_core::elements::{
|
|
Border, Fill, ListIndentLevel, ListNumbering, Margin, MouseStateHandle, Padding, ScrollData,
|
|
};
|
|
use galaxyui_core::fonts::{FamilyId, Properties, Weight};
|
|
use galaxyui_core::geometry::rect::RectF;
|
|
use galaxyui_core::geometry::vector::{Vector2F, vec2f};
|
|
use galaxyui_core::platform::LineStyle;
|
|
use galaxyui_core::text_layout::{CaretPosition, LayoutCache, Line, TextFrame};
|
|
use galaxyui_core::text_selection_utils::{
|
|
NewlineTickParams, calculate_tick_width, create_newline_tick_rect,
|
|
selection_crosses_newline_offset_based,
|
|
};
|
|
use galaxyui_core::units::{IntoPixels, Pixels};
|
|
use galaxyui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle};
|
|
|
|
use self::location::WrapDirection;
|
|
pub use self::location::{HitTestOptions, Location};
|
|
pub use self::offset_map::{OffsetMap, SelectableTextRun};
|
|
pub use self::positioned::Positioned;
|
|
use self::positioned::PositionedCursor;
|
|
use self::saved_positions::SavedPositions;
|
|
use self::viewport::{
|
|
ScrollPositionSnapshot, SizeInfo, ViewportItem, ViewportIterator, ViewportState,
|
|
};
|
|
use super::BLOCK_FOOTER_HEIGHT;
|
|
use super::element::broken_embedding::RenderableBrokenEmbedding;
|
|
use super::element::{CursorData, RenderContext, RenderableBlock};
|
|
use super::layout::{TextLayout, line_height};
|
|
use crate::content::edit::{
|
|
EditDelta, LaidOutRenderDelta, ParsedUrl, TemporaryBlock, layout_temporary_blocks,
|
|
};
|
|
use crate::content::hidden_lines_model::HiddenLinesModel;
|
|
use crate::content::markdown::MarkdownStyle;
|
|
use crate::content::text::{BlockHeaderSize, BufferBlockStyle, CodeBlockType, FormattedTable};
|
|
use crate::content::version::BufferVersion;
|
|
use crate::editor::EmbeddedItemModel;
|
|
use crate::render::model::debug::Describe;
|
|
|
|
pub mod bounds;
|
|
pub(crate) mod debug;
|
|
mod location;
|
|
mod offset_map;
|
|
mod positioned;
|
|
pub mod saved_positions;
|
|
pub mod table_offset_map;
|
|
pub mod viewport;
|
|
|
|
#[cfg(test)]
|
|
#[path = "mod_tests.rs"]
|
|
mod tests;
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod test_utils;
|
|
|
|
/// Margin for comparing pixel or line values. This is fairly wide because
|
|
/// scrolling can introduce a large amount of floating-point rounding error.
|
|
pub const UNIT_MARGIN: (f32, i32) = (0.01, 2);
|
|
const AUTO_SCROLL_MARGIN: f32 = 12.;
|
|
|
|
/// The minimum height of a paragraph, not including padding or margins.
|
|
pub const PARAGRAPH_MIN_HEIGHT: Pixels = Pixels::new(24.);
|
|
const TABLE_SCROLL_REVEAL_MARGIN: Pixels = Pixels::new(8.);
|
|
|
|
pub const EMBEDDED_ITEM_FIRST_LINE_HEIGHT: f32 = 24.;
|
|
|
|
pub const TEXT_SPACING: BlockSpacing = BlockSpacing {
|
|
margin: Margin::uniform(0.).with_right(16.),
|
|
padding: Padding::uniform(0.),
|
|
};
|
|
|
|
pub const COMMAND_SPACING: BlockSpacing = BlockSpacing {
|
|
margin: Margin::uniform(0.)
|
|
.with_top(8.)
|
|
.with_left(4.)
|
|
.with_bottom(8.)
|
|
.with_right(16.),
|
|
padding: Padding::uniform(8.)
|
|
.with_left(16.)
|
|
.with_top(16.)
|
|
// Reserve space for the buttons.
|
|
.with_bottom(BLOCK_FOOTER_HEIGHT),
|
|
};
|
|
|
|
pub const BROKEN_LINK_SPACING: BlockSpacing = BlockSpacing {
|
|
margin: Margin::uniform(0.)
|
|
.with_top(8.)
|
|
.with_left(4.)
|
|
.with_bottom(8.)
|
|
.with_right(16.),
|
|
padding: Padding::uniform(0.)
|
|
.with_left(12.)
|
|
.with_top(18.)
|
|
.with_bottom(18.)
|
|
.with_right(8.),
|
|
};
|
|
|
|
pub const HEADER_SPACING: BlockSpacing = BlockSpacing {
|
|
margin: Margin::uniform(0.)
|
|
.with_top(4.)
|
|
.with_bottom(4.)
|
|
.with_right(16.),
|
|
padding: Padding::uniform(0.),
|
|
};
|
|
|
|
pub const UNORDERED_LIST_MARGIN: Margin = Margin::uniform(4.).with_right(16.);
|
|
pub const UNIT_UNORDERED_LIST_PADDING: f32 = 20.;
|
|
|
|
pub const ORDERED_LIST_MARGIN: Margin = Margin::uniform(4.).with_right(16.);
|
|
pub const UNIT_ORDERED_LIST_PADDING: f32 = 20.;
|
|
|
|
pub const TASK_LIST_MARGIN: Margin = Margin::uniform(4.).with_right(16.);
|
|
pub const UNIT_TASK_LIST_PADDING: f32 = 20.;
|
|
|
|
pub const DEFAULT_BLOCK_SPACINGS: BlockSpacings = BlockSpacings {
|
|
text: TEXT_SPACING,
|
|
header: HEADER_SPACING,
|
|
code_block: COMMAND_SPACING,
|
|
task_list: IndentableBlockSpacing {
|
|
margin: TASK_LIST_MARGIN,
|
|
unit_padding: UNIT_TASK_LIST_PADDING,
|
|
},
|
|
ordered_list: IndentableBlockSpacing {
|
|
margin: ORDERED_LIST_MARGIN,
|
|
unit_padding: UNIT_ORDERED_LIST_PADDING,
|
|
},
|
|
unordered_list: IndentableBlockSpacing {
|
|
margin: UNORDERED_LIST_MARGIN,
|
|
unit_padding: UNIT_UNORDERED_LIST_PADDING,
|
|
},
|
|
};
|
|
|
|
const MIN_HIDDEN_BLOCK_WIDTH: Pixels = Pixels::new(20.);
|
|
const HIDDEN_BLOCK_HEIGHT: Pixels = Pixels::new(20.);
|
|
pub const CODE_EDITOR_HIDDEN_SECTION_EXPANSION_LINES: usize = 25;
|
|
|
|
/// Thickness of underline decorations in pixels.
|
|
const UNDERLINE_THICKNESS: f32 = 2.;
|
|
/// Length of dashes in dashed underline decorations in pixels.
|
|
const DASHED_UNDERLINE_DASH_LENGTH: f32 = 4.;
|
|
/// Length of gaps in dashed underline decorations in pixels.
|
|
const DASHED_UNDERLINE_GAP_LENGTH: f32 = 4.;
|
|
|
|
/// In the future, we should also support MinimumWidth(f32) setting so the content will
|
|
/// be laid out with a minimum width that could be larger than the viewport.
|
|
#[derive(Default)]
|
|
pub enum WidthSetting {
|
|
#[default]
|
|
FitViewport,
|
|
InfiniteWidth,
|
|
}
|
|
|
|
/// Block types that support hit-testing on the block.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum HitTestBlockType {
|
|
Code,
|
|
MermaidDiagram,
|
|
Embedding,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct RenderLayoutOptions {
|
|
pub render_mermaid_diagrams: bool,
|
|
pub mermaid_render_offsets: HashSet<CharOffset>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum StyleUpdateAction {
|
|
Relayout,
|
|
Repaint,
|
|
None,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct RenderBufferVersion {
|
|
last_rendered_version: Option<BufferVersion>,
|
|
next_render_version: Option<BufferVersion>,
|
|
}
|
|
|
|
impl RenderBufferVersion {
|
|
fn start_layout(&mut self) -> Option<BufferVersion> {
|
|
// When layout starts, we eagerly set the last rendered version to the current version we are rendering.
|
|
self.last_rendered_version = self.next_render_version;
|
|
self.last_rendered_version
|
|
}
|
|
}
|
|
|
|
/// Render time decoration that should be applied on a specific range of text / lines.
|
|
///
|
|
/// Use decorations for transient styles that do not affect the buffer or require text layout.
|
|
/// * Persistent styles should be applied as inline markers that are saved as part of the buffer.
|
|
/// * Transient styles that affect layout, like syntax highlighting, can't be applied when
|
|
/// rendering, so they need to be modeled in the buffer.
|
|
#[derive(Default, Debug)]
|
|
pub struct RenderDecoration {
|
|
text: Vec<Decoration>,
|
|
line: Vec<LineDecoration>,
|
|
}
|
|
|
|
impl RenderDecoration {
|
|
pub(super) fn text(&self) -> &[Decoration] {
|
|
&self.text
|
|
}
|
|
|
|
pub fn line_decoration_ranges(&self) -> &[LineDecoration] {
|
|
&self.line
|
|
}
|
|
}
|
|
|
|
/// Wrapper around a reference to the underlying render state sumtree.
|
|
/// This is so we could define an interface that returns objects with the same lifetime as the inner
|
|
/// reference with interior mutability.
|
|
pub struct RenderContentTreeRef<'a>(Ref<'a, SumTree<BlockItem>>);
|
|
|
|
impl<'a> RenderContentTreeRef<'a> {
|
|
pub fn block_items(&self) -> impl Iterator<Item = &BlockItem> {
|
|
let mut cursor = self.0.cursor::<(), ()>();
|
|
cursor.descend_to_first_item(&self.0, |_| true);
|
|
std::iter::from_fn(move || {
|
|
let item = cursor.item()?;
|
|
cursor.next();
|
|
Some(item)
|
|
})
|
|
}
|
|
/// Iterator over items visible in the current viewport.
|
|
///
|
|
/// This returns both the `ViewportItem` and the backing `BlockItem`, for identifying what kind
|
|
/// of item it is. It does not directly return `RenderableBlock`s, as they may depend on
|
|
/// higher-level state.
|
|
pub fn viewport_items(
|
|
&self,
|
|
viewport_height: Pixels,
|
|
viewport_width: Pixels,
|
|
scroll_top: Pixels,
|
|
) -> impl Iterator<Item = (ViewportItem, &BlockItem)> {
|
|
ViewportIterator::new(&self.0, scroll_top, viewport_height, viewport_width)
|
|
}
|
|
|
|
/// Describe only the content of the rendering model.
|
|
#[cfg(test)]
|
|
pub fn describe_content(&self) -> impl fmt::Display + '_ {
|
|
self.0.describe()
|
|
}
|
|
|
|
pub fn block_at_height(&self, height: f64) -> Option<Positioned<'_, BlockItem>> {
|
|
let height = Height(OrderedFloat(height));
|
|
|
|
let mut cursor = self.0.cursor::<Height, LayoutSummary>();
|
|
// For height, we don't need to seek to exactly the starting height of the block.
|
|
cursor.seek(&height, SeekBias::Right);
|
|
cursor.positioned_item()
|
|
}
|
|
|
|
/// Returns the 0-based index of the temporary block at the given content-
|
|
/// space height within its consecutive run of temporary blocks.
|
|
///
|
|
/// Uses two O(log n) sumtree cursor seeks: one by height to locate the
|
|
/// target block, and one by character offset to find the start of the
|
|
/// temporary-block run. The index is the difference in cumulative item
|
|
/// counts between the two positions.
|
|
///
|
|
/// Returns `None` if the block at that height is not a `TemporaryBlock`.
|
|
pub fn temp_block_hunk_index_at_height(&self, height: f64) -> Option<usize> {
|
|
let height = Height(OrderedFloat(height));
|
|
|
|
// Seek by height to find the target temporary block.
|
|
let mut height_cursor = self.0.cursor::<Height, LayoutSummary>();
|
|
height_cursor.seek(&height, SeekBias::Right);
|
|
|
|
// Verify we landed on a temporary block.
|
|
if !matches!(height_cursor.item()?, BlockItem::TemporaryBlock { .. }) {
|
|
return None;
|
|
}
|
|
|
|
let target_item_count = height_cursor.start().item_count;
|
|
let boundary_offset = height_cursor.start().content_length;
|
|
|
|
// Seek by CharOffset to the same content-length boundary. With Left
|
|
// bias this lands on the last non-temporary block before the run
|
|
// (temporary blocks have content_length == 0, so they don't advance
|
|
// the CharOffset dimension).
|
|
let mut offset_cursor = self.0.cursor::<CharOffset, LayoutSummary>();
|
|
offset_cursor.seek(&boundary_offset, SeekBias::Left);
|
|
|
|
// If the offset cursor itself landed on a temporary block, the run
|
|
// starts at the very beginning of the tree (no preceding regular
|
|
// block). Use start().item_count as the boundary.
|
|
let boundary_item_count =
|
|
if matches!(offset_cursor.item(), Some(BlockItem::TemporaryBlock { .. })) {
|
|
offset_cursor.start().item_count
|
|
} else {
|
|
offset_cursor.end().item_count
|
|
};
|
|
|
|
Some(target_item_count - boundary_item_count)
|
|
}
|
|
|
|
pub fn block_at_offset(&self, offset: CharOffset) -> Option<Positioned<'_, BlockItem>> {
|
|
let mut cursor = self.0.cursor::<CharOffset, LayoutSummary>();
|
|
if cursor.seek(&offset, SeekBias::Right) {
|
|
cursor.positioned_item()
|
|
} else {
|
|
// If we can't seek exactly to the starting CharOffset of the block, the render model
|
|
// has probably changed since this item was created. To be safe, fail the lookup.
|
|
log::trace!("ViewportItem invalidated: no block starting at {offset}");
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn is_entire_range_of_type(
|
|
&self,
|
|
range: &Range<CharOffset>,
|
|
mut matches_type: impl FnMut(&BlockItem) -> bool,
|
|
) -> bool {
|
|
if range.start >= range.end {
|
|
return false;
|
|
}
|
|
|
|
let Some(block) = self.block_at_offset(range.start) else {
|
|
return false;
|
|
};
|
|
|
|
block.start_char_offset == range.start
|
|
&& block.end_char_offset() == range.end
|
|
&& matches_type(block.item)
|
|
}
|
|
|
|
pub fn mermaid_block_ranges(&self) -> Vec<Range<CharOffset>> {
|
|
let mut cursor = self.0.cursor::<(), LayoutSummary>();
|
|
cursor.descend_to_first_item(&self.0, |_| true);
|
|
|
|
let mut ranges = Vec::new();
|
|
while let Some(item) = cursor.item() {
|
|
if matches!(item, BlockItem::MermaidDiagram { .. }) {
|
|
let start = cursor.start().content_length;
|
|
let end = start + item.content_length();
|
|
ranges.push(start..end);
|
|
}
|
|
cursor.next();
|
|
}
|
|
|
|
ranges
|
|
}
|
|
|
|
/// Returns the cumulative Y offset (in content-space pixels) at the given line.
|
|
///
|
|
/// When `line >= total_lines`, returns the total content height.
|
|
pub fn y_offset_at_line(&self, line: LineCount) -> Pixels {
|
|
let summary = self.0.summary();
|
|
if line >= summary.lines {
|
|
return (summary.height as f32).into_pixels();
|
|
}
|
|
let mut cursor = self.0.cursor::<LineCount, LayoutSummary>();
|
|
cursor.seek_clamped(&line, SeekBias::Right);
|
|
(cursor.start().height as f32).into_pixels()
|
|
}
|
|
}
|
|
|
|
/// All state specific to the TUI char-cell rendering path.
|
|
///
|
|
/// Bundled into a single struct so the [`LayoutMode`] enum cleanly separates
|
|
/// mode-specific data; fields that are only meaningful in one mode are never
|
|
/// scattered across the parent [`RenderState`].
|
|
pub struct CharCellState {
|
|
/// Terminal width in character columns. Pushed from the element's layout
|
|
/// pass; interior-mutable so it can be set through a shared `&CharCellState`
|
|
/// (mirroring how the GUI submits viewport state during layout).
|
|
pub(crate) terminal_width: Cell<u16>,
|
|
/// The 0-indexed char offset of the start of each logical line (`\n`-split).
|
|
/// Rebuilt synchronously via [`CharCellState::update_text`]. Invariant:
|
|
/// never empty — it always holds at least `[0]` (logical line 0 starts at char
|
|
/// 0), even before the first edit, so the soft-wrap helpers can index it safely.
|
|
pub(crate) line_starts: RefCell<Vec<usize>>,
|
|
/// The terminal display width (in cells) of each character of the buffer
|
|
/// text, indexed in parallel with char offsets. This is **derived layout
|
|
/// metadata, not a copy of the text**: char-cell wrapping only needs each
|
|
/// char's width, and the render-state query methods are `&self` with no
|
|
/// `AppContext`, so they can't read the `Buffer` model at query time. One
|
|
/// byte per char (0 for zero-width/combining marks, 2 for wide CJK/emoji, 1
|
|
/// otherwise). Rebuilt via [`CharCellState::update_text`].
|
|
pub(crate) char_widths: RefCell<Vec<u8>>,
|
|
}
|
|
|
|
impl CharCellState {
|
|
fn new(terminal_width: u16) -> Self {
|
|
Self {
|
|
terminal_width: Cell::new(terminal_width),
|
|
// Seed with logical line 0 so `line_starts` is never empty, matching
|
|
// the post-`update_text` state for an empty buffer. The soft-wrap
|
|
// helpers index `line_starts[0]` and must not panic if a navigation/
|
|
// scroll happens before the first edit.
|
|
line_starts: RefCell::new(vec![0]),
|
|
char_widths: RefCell::new(Vec::new()),
|
|
}
|
|
}
|
|
|
|
/// The terminal width (in cells) used for char-cell wrapping.
|
|
pub fn terminal_width(&self) -> u16 {
|
|
self.terminal_width.get()
|
|
}
|
|
|
|
/// Update the terminal width used for char-cell wrapping. Interior-mutable so
|
|
/// it can be set through a shared `&CharCellState` during the element's layout
|
|
/// pass (which only has a shared `&AppContext`). `line_starts`/`char_widths`
|
|
/// don't depend on the width, so nothing else is rebuilt here.
|
|
pub fn set_terminal_width(&self, terminal_width: u16) {
|
|
self.terminal_width.set(terminal_width);
|
|
}
|
|
|
|
/// Rebuild the char-cell layout index — `line_starts` and the per-char display
|
|
/// `char_widths` — from the current buffer `text` (O(n) char scan).
|
|
///
|
|
/// ## Why TUI needs this explicit call but GUI doesn't
|
|
///
|
|
/// The GUI keeps layout in sync via an async font-shaping pipeline
|
|
/// (`update_content` → `ContentChanged` → `layout_tx` → `handle_layout_action`),
|
|
/// which `offset_to_softwrap_point` then reads. `LayoutMode::CharCell` skips that
|
|
/// channel (no font engine): the channel only carries an `EditDelta` (not the
|
|
/// full text this rebuild needs) and is async, whereas TUI cursor queries need
|
|
/// fresh `line_starts` synchronously within the same frame as the edit.
|
|
/// [`on_buffer_version_updated`](warp_editor::model::CoreEditorModel::on_buffer_version_updated)
|
|
/// is the guaranteed-synchronous post-edit hook that calls this.
|
|
///
|
|
/// `text` should be the buffer's current plain text (without any trailing
|
|
/// sentinel newline injected by the buffer layer).
|
|
pub fn update_text(&self, text: &str) {
|
|
let mut starts = self.line_starts.borrow_mut();
|
|
let mut char_widths = self.char_widths.borrow_mut();
|
|
starts.clear();
|
|
char_widths.clear();
|
|
// Logical line 0 starts at char index 0.
|
|
starts.push(0_usize);
|
|
for ch in text.chars() {
|
|
// Store only the derived display width, never the character itself.
|
|
char_widths.push(char_cell_display_width(ch) as u8);
|
|
if ch == '\n' {
|
|
// The next logical line starts at the char index after this '\n'.
|
|
starts.push(char_widths.len());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for CharCellState {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("CharCellState")
|
|
.field("terminal_width", &self.terminal_width.get())
|
|
.field("line_starts_len", &self.line_starts.borrow().len())
|
|
.field("total_chars", &self.char_widths.borrow().len())
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// Determines which soft-wrap layout pipeline `RenderState` uses.
|
|
///
|
|
/// - [`LayoutMode::Pixels`]: font-aware pixel layout for the GPU-rendered GUI. Uses the
|
|
/// `SumTree<BlockItem>` content tree and the async font-shaping channel.
|
|
/// - [`LayoutMode::CharCell`]: monospace char-cell layout for the TUI. Skips font shaping;
|
|
/// computes positions from [`CharCellState`] using character-count arithmetic.
|
|
///
|
|
/// This is always set explicitly at construction — there is no sensible default since
|
|
/// the rendering path is determined by whether the client is a GUI or TUI.
|
|
#[derive(Debug)]
|
|
pub enum LayoutMode {
|
|
/// GPU-rendered GUI path: font-aware, pixel-based soft-wrap.
|
|
Pixels,
|
|
/// TUI path: monospace char-cell layout, no font engine required.
|
|
/// All TUI-specific state lives in [`CharCellState`].
|
|
CharCell(CharCellState),
|
|
}
|
|
|
|
/// Model for rendering rich text.
|
|
pub struct RenderState {
|
|
/// Content is wrapped in a RefCell so we could mutate it when we are laying out the editor element.
|
|
/// We know this is safe because there is a one-to-one relationship between element and model.
|
|
content: RefCell<SumTree<BlockItem>>,
|
|
|
|
selections: RefCell<RenderedSelectionSet>,
|
|
decorations: RenderDecoration,
|
|
hidden_lines: Option<ModelHandle<HiddenLinesModel>>,
|
|
|
|
/// Position IDs saved during paint.
|
|
saved_positions: SavedPositions,
|
|
|
|
/// State of the current viewport, which determines which items are visible.
|
|
viewport: ViewportState,
|
|
|
|
styles: RichTextStyles,
|
|
|
|
/// A terminal trailing newline is added after a styled block (for example, a code block)
|
|
/// so that the user can insert unstyled text after it.
|
|
/// This extra newline doesn't make sense in read-only rich text, so it can be disabled.
|
|
show_final_trailing_newline_when_non_empty: bool,
|
|
has_final_trailing_newline: Cell<bool>,
|
|
|
|
width_setting: WidthSetting,
|
|
|
|
/// Channel for propagating updates from [`super::element::RichTextElement`] such as the
|
|
/// viewport size.
|
|
element_tx: async_channel::Sender<ElementUpdate>,
|
|
/// Channel for laying out edits. Any model updates that require text layout are routed through
|
|
/// this channel, along with updates that must be ordered with respect to text layout (like
|
|
/// cursor movement).
|
|
layout_tx: async_channel::Sender<LayoutAction>,
|
|
|
|
/// A count of outstanding layouts.
|
|
#[cfg(any(test, feature = "test-util"))]
|
|
outstanding_layouts: Arc<std::sync::atomic::AtomicUsize>,
|
|
|
|
/// The render-related content version the model is managing.
|
|
buffer_version: RefCell<RenderBufferVersion>,
|
|
|
|
/// Whether we are performing a lazy layout.
|
|
lazy_layout: bool,
|
|
|
|
pending_edits: Mutex<Vec<PendingLayout>>,
|
|
pending_selection_change: Mutex<Option<PendingSelectionUpdate>>,
|
|
layout_options: RenderLayoutOptions,
|
|
|
|
/// Optional path to the document being rendered, used for resolving relative paths
|
|
/// (e.g. relative image paths in markdown).
|
|
document_path: Option<std::path::PathBuf>,
|
|
|
|
/// The active layout mode for soft-wrap computation.
|
|
/// For the TUI path (`CharCell`), this also carries all char-cell-specific state.
|
|
layout_mode: LayoutMode,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RenderEvent {
|
|
/// The viewport size changed, and so the editor model must re-layout its contents accordingly.
|
|
NeedsResize,
|
|
/// New edits have been applied to the editor model.
|
|
LayoutUpdated,
|
|
/// Pending edits were flushed during lazy layout.
|
|
PendingEditsFlushed,
|
|
ViewportUpdated(Option<BufferVersion>),
|
|
}
|
|
|
|
/// Styles for rendering rich text. This fills a similar role to `UiComponentStyles`,
|
|
/// but is specialized for text.
|
|
#[derive(Clone, PartialEq, Debug)]
|
|
pub struct RichTextStyles {
|
|
/// The text styles to use for regular body text.
|
|
pub base_text: ParagraphStyles,
|
|
/// The text styles to use for code.
|
|
pub code_text: ParagraphStyles,
|
|
/// The background fill to use for code blocks.
|
|
pub code_background: Fill,
|
|
/// The background fill to use for embeddings.
|
|
pub embedding_background: Fill,
|
|
/// The text styles to use for embeddings.
|
|
pub embedding_text: ParagraphStyles,
|
|
/// The border to use for code blocks.
|
|
pub code_border: Border,
|
|
/// The color to use for placeholder text.
|
|
pub placeholder_color: ColorU,
|
|
/// The fill to use for text selections.
|
|
pub selection_fill: Fill,
|
|
/// The fill to use for cursors.
|
|
pub cursor_fill: Fill,
|
|
/// Styling for inline code blocks.
|
|
pub inline_code_style: InlineCodeStyle,
|
|
/// Styling for inline checkbox.
|
|
pub check_box_style: CheckBoxStyle,
|
|
/// Styling for horizontal rules.
|
|
pub horizontal_rule_style: HorizontalRuleStyle,
|
|
/// Path to the broken link icon svg.
|
|
pub broken_link_style: BrokenLinkStyle,
|
|
/// Spacing configuration for blocks.
|
|
pub block_spacings: BlockSpacings,
|
|
/// Minimum height a paragraph will take. This currently
|
|
/// is only applied for some blocks like trailing cursor, text
|
|
/// and headers.
|
|
pub minimum_paragraph_height: Option<Pixels>,
|
|
/// Whether to show placeholder text on empty blocks.
|
|
pub show_placeholder_text_on_empty_block: bool,
|
|
/// Width of the cursor
|
|
pub cursor_width: f32,
|
|
/// Whether to highlight detected URLs.
|
|
pub highlight_urls: bool,
|
|
/// Styling for tables.
|
|
pub table_style: TableStyle,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Debug)]
|
|
pub struct IndentableBlockSpacing {
|
|
margin: Margin,
|
|
unit_padding: f32,
|
|
}
|
|
|
|
impl IndentableBlockSpacing {
|
|
pub fn to_spacing(&self, indent_level: ListIndentLevel) -> BlockSpacing {
|
|
BlockSpacing {
|
|
margin: self.margin,
|
|
padding: Padding::uniform(0.)
|
|
.with_left((indent_level.as_usize() as f32 + 1.) * self.unit_padding),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Debug)]
|
|
pub struct BlockSpacings {
|
|
pub text: BlockSpacing,
|
|
pub header: BlockSpacing,
|
|
pub code_block: BlockSpacing,
|
|
pub task_list: IndentableBlockSpacing,
|
|
pub ordered_list: IndentableBlockSpacing,
|
|
pub unordered_list: IndentableBlockSpacing,
|
|
}
|
|
|
|
impl Default for BlockSpacings {
|
|
fn default() -> Self {
|
|
DEFAULT_BLOCK_SPACINGS
|
|
}
|
|
}
|
|
|
|
impl BlockSpacings {
|
|
pub fn from_block_style(&self, block_type: &BufferBlockStyle) -> BlockSpacing {
|
|
match block_type {
|
|
BufferBlockStyle::Header { .. } => self.header,
|
|
BufferBlockStyle::OrderedList { indent_level, .. } => {
|
|
self.ordered_list.to_spacing(*indent_level)
|
|
}
|
|
BufferBlockStyle::UnorderedList { indent_level } => {
|
|
self.unordered_list.to_spacing(*indent_level)
|
|
}
|
|
BufferBlockStyle::TaskList { indent_level, .. } => {
|
|
self.task_list.to_spacing(*indent_level)
|
|
}
|
|
BufferBlockStyle::PlainText | BufferBlockStyle::Table { .. } => self.text,
|
|
BufferBlockStyle::CodeBlock { .. } => self.code_block,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Grouping of font-related styles for rendering a specific category of text
|
|
/// (such as code or headings). In most word processors, these are referred to
|
|
/// as paragraph styles, so we keep that naming here.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct ParagraphStyles {
|
|
pub font_family: FamilyId,
|
|
pub font_size: f32,
|
|
pub font_weight: Weight,
|
|
pub line_height_ratio: f32,
|
|
pub text_color: ColorU,
|
|
pub baseline_ratio: f32,
|
|
/// Fixed-width tab stop size in spaces (intended only for fully monospace paragraphs).
|
|
pub fixed_width_tab_size: Option<u8>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct CheckBoxStyle {
|
|
pub border_width: f32,
|
|
pub border_color: ColorU,
|
|
pub icon_path: &'static str,
|
|
pub background: ColorU,
|
|
pub hover_background: ColorU,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct BrokenLinkStyle {
|
|
pub icon_path: &'static str,
|
|
pub icon_color: ColorU,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct HorizontalRuleStyle {
|
|
pub rule_height: f32,
|
|
pub color: ColorU,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct TableStyle {
|
|
pub border_color: ColorU,
|
|
pub header_background: ColorU,
|
|
pub cell_background: ColorU,
|
|
pub alternate_row_background: Option<ColorU>,
|
|
pub text_color: ColorU,
|
|
pub header_text_color: ColorU,
|
|
pub scrollbar_nonactive_thumb_color: ColorU,
|
|
pub scrollbar_active_thumb_color: ColorU,
|
|
pub font_family: FamilyId,
|
|
pub font_size: f32,
|
|
pub cell_padding: f32,
|
|
pub outer_border: bool,
|
|
pub column_dividers: bool,
|
|
pub row_dividers: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct InlineCodeStyle {
|
|
pub font_family: FamilyId,
|
|
pub background: ColorU,
|
|
pub font_color: ColorU,
|
|
}
|
|
|
|
impl InlineCodeStyle {
|
|
pub fn requires_relayout(&self, new_styles: &InlineCodeStyle) -> bool {
|
|
self.font_family != new_styles.font_family
|
|
}
|
|
}
|
|
|
|
impl TableStyle {
|
|
pub fn requires_relayout(&self, new_styles: &TableStyle) -> bool {
|
|
self.font_family != new_styles.font_family
|
|
|| self.font_size != new_styles.font_size
|
|
|| self.cell_padding != new_styles.cell_padding
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq)]
|
|
pub struct LayoutSummary {
|
|
content_length: CharOffset,
|
|
height: f64,
|
|
width: Pixels,
|
|
lines: LineCount,
|
|
item_count: usize,
|
|
}
|
|
|
|
/// Rich text height, in pixels. This wrapper makes the dimension clear (height,
|
|
/// not width), and implements SumTree requirements.
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub struct Height(OrderedFloat<f64>);
|
|
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub struct Width(OrderedFloat<Pixels>);
|
|
|
|
#[derive(
|
|
Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
|
|
)]
|
|
pub struct LineCount(usize);
|
|
|
|
impl_offset!(LineCount);
|
|
|
|
impl LineCount {
|
|
pub fn as_u32(&self) -> u32 {
|
|
self.0 as u32
|
|
}
|
|
}
|
|
|
|
/// The unit used for the horizontal column component of a [`SoftWrapPoint`].
|
|
///
|
|
/// - [`ColumnUnit::Pixels`] is used by the GUI (font-aware, proportional) rendering path.
|
|
/// - [`ColumnUnit::Chars`] is used by the TUI (monospace, char-cell) rendering path.
|
|
///
|
|
/// The two variants must never be mixed in a single comparison or arithmetic expression.
|
|
/// Call sites that work in only one coordinate space should pattern-match and unwrap the
|
|
/// expected variant; cross-variant operations panic.
|
|
#[derive(Debug, Copy, Clone, PartialEq)]
|
|
pub enum ColumnUnit {
|
|
/// Horizontal offset in pixels, used by the GPU-rendered GUI path.
|
|
Pixels(Pixels),
|
|
/// Horizontal offset in terminal character columns, used by the TUI path.
|
|
Chars(u16),
|
|
}
|
|
|
|
impl ColumnUnit {
|
|
/// Zero column in pixel units (GUI path).
|
|
pub fn pixels_zero() -> Self {
|
|
ColumnUnit::Pixels(Pixels::zero())
|
|
}
|
|
|
|
/// Zero column in char units (TUI path).
|
|
pub fn chars_zero() -> Self {
|
|
ColumnUnit::Chars(0)
|
|
}
|
|
|
|
/// Returns the element-wise max of two same-variant values.
|
|
/// Variants must match; mixing Pixels and Chars is always a bug.
|
|
pub fn col_max(self, other: ColumnUnit) -> ColumnUnit {
|
|
match (self, other) {
|
|
(ColumnUnit::Pixels(a), ColumnUnit::Pixels(b)) => ColumnUnit::Pixels(a.max(b)),
|
|
(ColumnUnit::Chars(a), ColumnUnit::Chars(b)) => ColumnUnit::Chars(a.max(b)),
|
|
_ => {
|
|
debug_assert!(
|
|
false,
|
|
"ColumnUnit::col_max: mixed Pixels and Chars variants — this is a bug"
|
|
);
|
|
self // graceful fallback: keep self unchanged
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Unwraps the pixel value.
|
|
/// Calling this on a `Chars` variant is always a bug; in debug builds it asserts,
|
|
/// in release builds it returns [`Pixels::zero()`] as a safe fallback.
|
|
pub fn as_pixels(self) -> Pixels {
|
|
match self {
|
|
ColumnUnit::Pixels(p) => p,
|
|
ColumnUnit::Chars(_) => {
|
|
debug_assert!(
|
|
false,
|
|
"ColumnUnit::as_pixels called on a Chars variant — this is a bug"
|
|
);
|
|
Pixels::zero()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Unwraps the char-column value.
|
|
/// Calling this on a `Pixels` variant is always a bug; in debug builds it asserts,
|
|
/// in release builds it returns `0` as a safe fallback.
|
|
pub fn as_chars(self) -> u16 {
|
|
match self {
|
|
ColumnUnit::Chars(c) => c,
|
|
ColumnUnit::Pixels(_) => {
|
|
debug_assert!(
|
|
false,
|
|
"ColumnUnit::as_chars called on a Pixels variant — this is a bug"
|
|
);
|
|
0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A character offset within a [`TextFrame`]. These offsets count characters in the Rust string
|
|
/// passed to [`galaxyui_core::text_layout::LayoutCache::layout_text()`].
|
|
///
|
|
/// Frame offsets often, but not always, correspond to glyph indices and caret positions. However,
|
|
/// they do not line up 1:1 if a glyph or grapheme contains multiple characters
|
|
///
|
|
/// They often, but not always, line up with [`CharOffset`]s in the buffer. This is not the case
|
|
/// for placeholder text (which occupies 1 character in the buffer, but several in the text frame).
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub struct FrameOffset(usize);
|
|
|
|
impl_offset!(FrameOffset);
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub enum RenderLineLocation {
|
|
/// Referring to a temporary block in the render state.
|
|
Temporary {
|
|
at_line: LineCount,
|
|
/// Number of blocks from the at line.
|
|
index_from_at_line: usize,
|
|
},
|
|
/// Referring to a line that exists in the current buffer.
|
|
Current(LineCount),
|
|
}
|
|
|
|
impl RenderLineLocation {
|
|
pub fn line_count(&self) -> LineCount {
|
|
match self {
|
|
RenderLineLocation::Temporary { at_line, .. } => *at_line,
|
|
RenderLineLocation::Current(line_count) => *line_count,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A point within the editor. Unlike character and hard-wrap offsets/points, this accounts for
|
|
/// soft-wrapping, the layout of different block types, and proportional fonts.
|
|
///
|
|
/// Because soft-wrapping depends on the fonts and viewport size, `SoftWrapPoint` is not stable
|
|
/// across resizes or style changes.
|
|
///
|
|
/// The `column` field uses [`ColumnUnit`] to distinguish GUI (pixel) and TUI (char-cell)
|
|
/// coordinate spaces. Callers must use the same variant consistently within a rendering path.
|
|
#[derive(Debug, Copy, Clone, PartialEq)]
|
|
pub struct SoftWrapPoint {
|
|
/// A soft-wrapped line index within the laid-out document.
|
|
row: u32,
|
|
/// The point's horizontal position, in either pixels (GUI) or character columns (TUI).
|
|
/// See [`ColumnUnit`] for details.
|
|
column: ColumnUnit,
|
|
}
|
|
|
|
impl SoftWrapPoint {
|
|
pub fn new(row: u32, column: ColumnUnit) -> Self {
|
|
Self { row, column }
|
|
}
|
|
|
|
/// Move to the previous row with the same column.
|
|
pub fn previous_row(mut self) -> Option<Self> {
|
|
self.row = self.row.checked_sub(1)?;
|
|
Some(self)
|
|
}
|
|
|
|
/// Move to the next row with the same column. This is bound by the max row count.
|
|
pub fn next_row(mut self, max_row: LineCount) -> Option<Self> {
|
|
let next_row = self.row + 1;
|
|
if next_row > max_row.0 as u32 {
|
|
None
|
|
} else {
|
|
self.row = next_row;
|
|
Some(self)
|
|
}
|
|
}
|
|
|
|
pub fn row(&self) -> u32 {
|
|
self.row
|
|
}
|
|
|
|
pub fn column(&self) -> ColumnUnit {
|
|
self.column
|
|
}
|
|
}
|
|
|
|
/// A block of rich text, like a paragraph, runnable command, or list item.
|
|
#[derive(Debug, Clone)]
|
|
pub enum BlockItem {
|
|
Paragraph(Paragraph),
|
|
TextBlock {
|
|
paragraph_block: ParagraphBlock,
|
|
},
|
|
TemporaryBlock {
|
|
paragraph_block: ParagraphBlock,
|
|
text_decoration: Vec<Decoration>,
|
|
decoration: Option<ThemeFill>,
|
|
},
|
|
RunnableCodeBlock {
|
|
paragraph_block: ParagraphBlock,
|
|
code_block_type: CodeBlockType,
|
|
/// For Mermaid code blocks that are currently rendered in code-block view because the
|
|
/// Mermaid source is not yet available as a rendered diagram, this carries the asset
|
|
/// source that the view layer can watch. Once the asset transitions to a successful
|
|
/// load, the layout will re-run and emit [`BlockItem::MermaidDiagram`] instead.
|
|
pending_mermaid_asset: Option<AssetSource>,
|
|
},
|
|
MermaidDiagram {
|
|
content_length: CharOffset,
|
|
asset_source: AssetSource,
|
|
config: ImageBlockConfig,
|
|
},
|
|
TaskList {
|
|
indent_level: ListIndentLevel,
|
|
complete: bool,
|
|
paragraph: Paragraph,
|
|
mouse_state: MouseStateHandle,
|
|
},
|
|
UnorderedList {
|
|
indent_level: ListIndentLevel,
|
|
paragraph: Paragraph,
|
|
},
|
|
OrderedList {
|
|
indent_level: ListIndentLevel,
|
|
number: Option<usize>,
|
|
paragraph: Paragraph,
|
|
},
|
|
Header {
|
|
header_size: BlockHeaderSize,
|
|
paragraph: Paragraph,
|
|
},
|
|
Embedded(Arc<dyn LaidOutEmbeddedItem>),
|
|
HorizontalRule(HorizontalRuleConfig),
|
|
Image {
|
|
alt_text: String,
|
|
source: String,
|
|
asset_source: AssetSource,
|
|
config: ImageBlockConfig,
|
|
},
|
|
Table(Box<LaidOutTable>),
|
|
TrailingNewLine(Cursor),
|
|
Hidden(HiddenBlockConfig),
|
|
}
|
|
|
|
pub struct EmbeddedItemHTMLRepresentation<'a> {
|
|
pub element_name: &'a str,
|
|
pub content: String,
|
|
pub attributes: HashMap<&'a str, &'a str>,
|
|
}
|
|
|
|
pub struct EmbeddedItemRichFormat<'a> {
|
|
pub html: EmbeddedItemHTMLRepresentation<'a>,
|
|
pub plain_text: String,
|
|
}
|
|
|
|
pub trait EmbeddedItem: std::fmt::Debug + Send + Sync {
|
|
// Layout the embedded item with the current text layout context.
|
|
fn layout(&self, text_layout: &TextLayout, app: &AppContext) -> Box<dyn LaidOutEmbeddedItem>;
|
|
fn hashed_id(&self) -> &str;
|
|
/// Serializes this item as YAML.
|
|
fn to_mapping(&self, style: MarkdownStyle) -> Mapping;
|
|
// Returns the rich format of the embedded item used for copy & pasting.
|
|
fn to_rich_format(&self, app: &AppContext) -> EmbeddedItemRichFormat<'_>;
|
|
}
|
|
|
|
pub trait LaidOutEmbeddedItem: std::fmt::Debug + Send + Sync {
|
|
fn height(&self) -> Pixels;
|
|
fn size(&self) -> Vector2F;
|
|
fn first_line_bound(&self) -> Vector2F;
|
|
fn element(
|
|
&self,
|
|
state: &RenderState,
|
|
viewport_item: ViewportItem,
|
|
model: Option<&dyn EmbeddedItemModel>,
|
|
ctx: &AppContext,
|
|
) -> Box<dyn RenderableBlock>;
|
|
fn spacing(&self) -> BlockSpacing;
|
|
/// Returns this object as a ref to the Any type. Needed for typecasts.
|
|
fn as_any(&self) -> &dyn Any;
|
|
}
|
|
|
|
#[derive(Default, Debug, Clone, Copy, PartialEq)]
|
|
pub struct BlockSpacing {
|
|
pub margin: Margin,
|
|
pub padding: Padding,
|
|
}
|
|
|
|
impl BlockSpacing {
|
|
// Total additional offset on the x-axis with padding and margin combined.
|
|
pub fn x_axis_offset(&self) -> Pixels {
|
|
(self.margin.left() + self.margin.right() + self.padding.left() + self.padding.right())
|
|
.into_pixels()
|
|
}
|
|
|
|
// Total additional offset on the y-axis with padding and margin combined.
|
|
pub fn y_axis_offset(&self) -> Pixels {
|
|
(self.margin.top() + self.margin.bottom() + self.padding.top() + self.padding.bottom())
|
|
.into_pixels()
|
|
}
|
|
|
|
pub fn top_offset(&self) -> Pixels {
|
|
(self.margin.top() + self.padding.top()).into_pixels()
|
|
}
|
|
|
|
pub fn left_offset(&self) -> Pixels {
|
|
(self.margin.left() + self.padding.left()).into_pixels()
|
|
}
|
|
|
|
fn without_y_axis_offsets(mut self) -> Self {
|
|
self.margin = Margin::default()
|
|
.with_left(self.margin.left())
|
|
.with_right(self.margin.right());
|
|
self.padding = Padding::default()
|
|
.with_left(self.padding.left())
|
|
.with_right(self.padding.right());
|
|
self
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ParagraphBlock {
|
|
paragraphs: Vec1<Paragraph>,
|
|
}
|
|
|
|
impl ParagraphBlock {
|
|
pub fn new(paragraphs: Vec1<Paragraph>) -> Self {
|
|
Self { paragraphs }
|
|
}
|
|
|
|
pub fn spacing(&self) -> BlockSpacing {
|
|
// In the future, we should support two separate level of spacing in a
|
|
// ParagraphBlock: 1) the internal spacing between paragraphs 2) the overall
|
|
// spacing of the block.
|
|
self.paragraphs.first().spacing()
|
|
}
|
|
|
|
pub fn first_line_height(&self) -> f32 {
|
|
self.paragraphs.first().first_line_height()
|
|
}
|
|
|
|
pub fn paragraphs(&self) -> &[Paragraph] {
|
|
&self.paragraphs
|
|
}
|
|
|
|
fn content_length(&self) -> CharOffset {
|
|
self.paragraphs
|
|
.iter()
|
|
.fold(CharOffset::zero(), |sum, paragraph| {
|
|
sum + paragraph.content_length
|
|
})
|
|
}
|
|
|
|
pub fn width(&self) -> Pixels {
|
|
self.paragraphs
|
|
.iter()
|
|
.map(|paragraph| paragraph.width().as_f32())
|
|
.max_by(|a, b| a.partial_cmp(b).expect("Tried to compare a NaN"))
|
|
.unwrap_or(0.)
|
|
.into_pixels()
|
|
}
|
|
|
|
pub fn height(&self) -> Pixels {
|
|
self.paragraphs
|
|
.iter()
|
|
.map(|paragraph| paragraph.height.as_f32())
|
|
.sum::<f32>()
|
|
.into_pixels()
|
|
}
|
|
|
|
/// The size of this paragraph block's content, as currently laid out.
|
|
pub fn content_size(&self) -> Vector2F {
|
|
let width = self
|
|
.paragraphs
|
|
.iter()
|
|
.map(|paragraph| paragraph.frame.max_width())
|
|
.reduce(f32::max)
|
|
.unwrap_or(0.);
|
|
vec2f(width, self.height().as_f32())
|
|
}
|
|
|
|
fn lines(&self) -> LineCount {
|
|
self.paragraphs
|
|
.iter()
|
|
.fold(LineCount(0), |sum, paragraph| sum + paragraph.lines())
|
|
}
|
|
|
|
/// Returns `true` if this paragraph block is effectively empty.
|
|
fn is_empty(&self) -> bool {
|
|
// If there are multiple empty paragraphs, consider the paragraph non-empty, since that
|
|
// implies the user added at least one line. If there are no paragraphs, or a single empty
|
|
// paragraph (more likely, given our layout logic), consider the whole block empty.
|
|
match self.paragraphs.iter().at_most_one() {
|
|
Ok(None) => true,
|
|
Ok(Some(paragraph)) => paragraph.is_empty(),
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct Paragraph {
|
|
/// Laid-out text content of this paragraph.
|
|
frame: Arc<TextFrame>,
|
|
/// Mapping between [`TextFrame`] characters and content characters.
|
|
offsets: OffsetMap,
|
|
/// Cached height of this paragraph's text frame.
|
|
height: Pixels,
|
|
width: Pixels,
|
|
/// Content length of this paragraph, in `char`s.
|
|
content_length: CharOffset,
|
|
detected_url: Vec<ParsedUrl>,
|
|
spacing: BlockSpacing,
|
|
minimum_height: Option<Pixels>,
|
|
}
|
|
|
|
impl Paragraph {
|
|
pub fn new(
|
|
frame: Arc<TextFrame>,
|
|
offsets: OffsetMap,
|
|
content_length: CharOffset,
|
|
active_url: Vec<ParsedUrl>,
|
|
spacing: BlockSpacing,
|
|
minimum_height: Option<Pixels>,
|
|
) -> Self {
|
|
let height = frame
|
|
.lines()
|
|
.iter()
|
|
.fold(0f32, |acc, line| acc + line_height(line))
|
|
.into_pixels();
|
|
|
|
let width = frame.max_width().into_pixels();
|
|
Self {
|
|
frame,
|
|
offsets,
|
|
height,
|
|
width,
|
|
content_length,
|
|
detected_url: active_url,
|
|
spacing,
|
|
minimum_height,
|
|
}
|
|
}
|
|
|
|
pub fn first_line_height(&self) -> f32 {
|
|
self.frame
|
|
.lines()
|
|
.first()
|
|
.map(line_height)
|
|
.unwrap_or(self.height.as_f32())
|
|
}
|
|
|
|
pub fn spacing(&self) -> BlockSpacing {
|
|
self.spacing
|
|
}
|
|
|
|
pub(super) fn frame(&self) -> &TextFrame {
|
|
&self.frame
|
|
}
|
|
|
|
/// Whether or not this paragraph is effectively empty.
|
|
pub(super) fn is_empty(&self) -> bool {
|
|
let lines = self.frame.lines();
|
|
lines.is_empty() || lines.iter().all(|line| line.runs.is_empty())
|
|
}
|
|
|
|
/// The height of this paragraph.
|
|
pub fn height(&self) -> Pixels {
|
|
self.height
|
|
}
|
|
|
|
pub fn width(&self) -> Pixels {
|
|
self.width
|
|
}
|
|
|
|
fn lines(&self) -> LineCount {
|
|
LineCount(self.frame.lines().len())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct HorizontalRuleConfig {
|
|
pub line_height: Pixels,
|
|
pub width: Pixels,
|
|
pub spacing: BlockSpacing,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct ImageBlockConfig {
|
|
pub width: Pixels,
|
|
pub height: Pixels,
|
|
pub spacing: BlockSpacing,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct TableBlockConfig {
|
|
pub width: Pixels,
|
|
pub spacing: BlockSpacing,
|
|
pub style: TableStyle,
|
|
}
|
|
|
|
/// Layout information for a single table cell, including line-level details
|
|
/// for proper text selection rendering in cells with wrapped text.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct CellLayout {
|
|
pub line_heights: Vec<f32>,
|
|
pub line_y_offsets: Vec<f32>,
|
|
pub line_char_ranges: Vec<Range<CharOffset>>,
|
|
pub line_widths: Vec<f32>,
|
|
pub line_caret_positions: Vec<Vec<CaretPosition>>,
|
|
}
|
|
|
|
impl CellLayout {
|
|
pub fn from_text_frame(frame: &TextFrame) -> Self {
|
|
let lines = frame.lines();
|
|
let mut line_heights = Vec::with_capacity(lines.len());
|
|
let mut line_y_offsets = Vec::with_capacity(lines.len());
|
|
let mut line_char_ranges = Vec::with_capacity(lines.len());
|
|
let mut line_widths = Vec::with_capacity(lines.len());
|
|
let mut line_caret_positions = Vec::with_capacity(lines.len());
|
|
let mut y_offset = 0.0;
|
|
|
|
for line in lines.iter() {
|
|
let height = line.font_size * line.line_height_ratio;
|
|
line_heights.push(height);
|
|
line_y_offsets.push(y_offset);
|
|
line_widths.push(line.width);
|
|
line_caret_positions.push(line.caret_positions.clone());
|
|
y_offset += height;
|
|
|
|
let char_start = line
|
|
.caret_positions
|
|
.first()
|
|
.map(|cp| cp.start_offset)
|
|
.unwrap_or(0);
|
|
let char_end = line
|
|
.caret_positions
|
|
.last()
|
|
.map(|cp| cp.last_offset + 1)
|
|
.unwrap_or(char_start);
|
|
line_char_ranges.push(CharOffset::from(char_start)..CharOffset::from(char_end));
|
|
}
|
|
|
|
Self {
|
|
line_heights,
|
|
line_y_offsets,
|
|
line_char_ranges,
|
|
line_widths,
|
|
line_caret_positions,
|
|
}
|
|
}
|
|
|
|
pub fn line_at_char_offset(&self, char_offset: CharOffset) -> Option<usize> {
|
|
for (i, range) in self.line_char_ranges.iter().enumerate() {
|
|
if char_offset < range.end {
|
|
return Some(i);
|
|
}
|
|
}
|
|
if !self.line_char_ranges.is_empty() {
|
|
Some(self.line_char_ranges.len() - 1)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn x_for_char_in_line(&self, line_idx: usize, char_offset: usize) -> f32 {
|
|
let Some(carets) = self.line_caret_positions.get(line_idx) else {
|
|
return 0.0;
|
|
};
|
|
let width = self.line_widths.get(line_idx).copied().unwrap_or(0.0);
|
|
for caret in carets {
|
|
if caret.contains_index(char_offset) {
|
|
return caret.position_in_line;
|
|
}
|
|
}
|
|
if carets
|
|
.first()
|
|
.is_some_and(|caret| char_offset < caret.start_offset)
|
|
{
|
|
0.0
|
|
} else {
|
|
width
|
|
}
|
|
}
|
|
|
|
pub fn line_at_y_offset(&self, y: f32) -> usize {
|
|
for i in 0..self.line_y_offsets.len() {
|
|
let line_top = self.line_y_offsets[i];
|
|
let line_bottom = line_top + self.line_heights.get(i).copied().unwrap_or(0.0);
|
|
if y >= line_top && y < line_bottom {
|
|
return i;
|
|
}
|
|
}
|
|
self.line_y_offsets.len().saturating_sub(1)
|
|
}
|
|
|
|
/// Returns the nearest character offset for a horizontal hit-test within a line.
|
|
///
|
|
/// The explicit caret list does not include the insertion point at the visual end of the
|
|
/// line, so we compare against both the stored caret positions and the implicit line-end
|
|
/// caret at `line_width`. That keeps table hit-testing from snapping to the last glyph when a
|
|
/// click near the right edge is visually closer to the position after it.
|
|
pub fn char_at_x_in_line(&self, line_idx: usize, x: f32) -> CharOffset {
|
|
let Some(range) = self.line_char_ranges.get(line_idx) else {
|
|
return CharOffset::zero();
|
|
};
|
|
if range.start >= range.end {
|
|
return range.start;
|
|
}
|
|
|
|
let Some(carets) = self.line_caret_positions.get(line_idx) else {
|
|
return range.start;
|
|
};
|
|
if carets.is_empty() || x <= 0.0 {
|
|
return range.start;
|
|
}
|
|
|
|
let line_width = self.line_widths.get(line_idx).copied().unwrap_or(0.0);
|
|
if x >= line_width {
|
|
return range.end;
|
|
}
|
|
let mut closest = range.start;
|
|
let mut closest_distance = f32::INFINITY;
|
|
|
|
for caret in carets {
|
|
let distance = (caret.position_in_line - x).abs();
|
|
if distance <= closest_distance {
|
|
closest = CharOffset::from(caret.start_offset).clamp(range.start, range.end);
|
|
closest_distance = distance;
|
|
}
|
|
}
|
|
|
|
if (line_width - x).abs() <= closest_distance {
|
|
range.end
|
|
} else {
|
|
closest
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct LaidOutTable {
|
|
pub table: FormattedTable,
|
|
pub config: TableBlockConfig,
|
|
pub row_heights: Vec<Pixels>,
|
|
pub column_widths: Vec<Pixels>,
|
|
pub total_height: Pixels,
|
|
pub offset_map: table_offset_map::TableOffsetMap,
|
|
pub content_length: CharOffset,
|
|
pub cell_offset_maps: Vec<Vec<table_offset_map::TableCellOffsetMap>>,
|
|
pub row_y_offsets: Vec<f32>,
|
|
pub col_x_offsets: Vec<f32>,
|
|
pub cell_text_frames: Vec<Vec<Arc<TextFrame>>>,
|
|
pub cell_layouts: Vec<Vec<CellLayout>>,
|
|
pub cell_links: Vec<Vec<Vec<ParsedUrl>>>,
|
|
pub scroll_left: Cell<Pixels>,
|
|
pub(crate) scrollbar_interaction_state: TableScrollbarInteractionState,
|
|
/// When `false`, the surrounding container already owns horizontal scrolling, so this table
|
|
/// should render at full intrinsic width without introducing its own clip, scrollbar, or
|
|
/// scroll event handling.
|
|
pub horizontal_scroll_allowed: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub(crate) struct TableScrollbarDragState {
|
|
pub start_position_x: Pixels,
|
|
pub start_scroll_left: Pixels,
|
|
pub scroll_data: ScrollData,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub(crate) struct TableScrollbarInteractionState {
|
|
drag_state: Cell<Option<TableScrollbarDragState>>,
|
|
hovered: Cell<bool>,
|
|
}
|
|
|
|
impl LaidOutTable {
|
|
pub fn height(&self) -> Pixels {
|
|
self.total_height
|
|
}
|
|
|
|
pub fn width(&self) -> Pixels {
|
|
self.config.width
|
|
}
|
|
|
|
pub fn spacing(&self) -> BlockSpacing {
|
|
self.config.spacing
|
|
}
|
|
|
|
pub fn content_length(&self) -> CharOffset {
|
|
self.content_length
|
|
}
|
|
|
|
pub fn viewport_width(&self, viewport_width: Pixels) -> Pixels {
|
|
if !self.horizontal_scroll_allowed {
|
|
return self.width();
|
|
}
|
|
viewport_width.min(self.width())
|
|
}
|
|
|
|
pub fn max_scroll_left(&self, viewport_width: Pixels) -> Pixels {
|
|
if !self.horizontal_scroll_allowed {
|
|
return Pixels::zero();
|
|
}
|
|
(self.width() - self.viewport_width(viewport_width)).max(Pixels::zero())
|
|
}
|
|
|
|
pub fn scroll_left(&self) -> Pixels {
|
|
if !self.horizontal_scroll_allowed {
|
|
return Pixels::zero();
|
|
}
|
|
self.scroll_left.get()
|
|
}
|
|
|
|
pub fn set_scroll_left(&self, scroll_left: Pixels, viewport_width: Pixels) -> bool {
|
|
if !self.horizontal_scroll_allowed {
|
|
return false;
|
|
}
|
|
let clamped = scroll_left
|
|
.max(Pixels::zero())
|
|
.min(self.max_scroll_left(viewport_width));
|
|
if clamped.approx_eq(self.scroll_left.get(), UNIT_MARGIN) {
|
|
false
|
|
} else {
|
|
self.scroll_left.set(clamped);
|
|
true
|
|
}
|
|
}
|
|
|
|
pub fn scroll_horizontally(&self, delta: Pixels, viewport_width: Pixels) -> bool {
|
|
if !self.horizontal_scroll_allowed {
|
|
return false;
|
|
}
|
|
self.set_scroll_left(self.scroll_left.get() - delta, viewport_width)
|
|
}
|
|
|
|
pub(crate) fn start_scrollbar_drag(&self, start_position_x: Pixels, scroll_data: ScrollData) {
|
|
self.scrollbar_interaction_state
|
|
.drag_state
|
|
.set(Some(TableScrollbarDragState {
|
|
start_position_x,
|
|
start_scroll_left: self.scroll_left(),
|
|
scroll_data,
|
|
}));
|
|
}
|
|
|
|
pub(crate) fn end_scrollbar_drag(&self) -> bool {
|
|
self.scrollbar_interaction_state.drag_state.take().is_some()
|
|
}
|
|
|
|
pub(crate) fn scrollbar_drag_state(&self) -> Option<TableScrollbarDragState> {
|
|
self.scrollbar_interaction_state.drag_state.get()
|
|
}
|
|
|
|
pub(crate) fn scrollbar_hovered(&self) -> bool {
|
|
self.scrollbar_interaction_state.hovered.get()
|
|
}
|
|
|
|
pub(crate) fn set_scrollbar_hovered(&self, hovered: bool) -> bool {
|
|
self.scrollbar_interaction_state.hovered.replace(hovered) != hovered
|
|
}
|
|
|
|
pub(crate) fn clear_scrollbar_interaction_state(&self) {
|
|
self.scrollbar_interaction_state.drag_state.set(None);
|
|
self.scrollbar_interaction_state.hovered.set(false);
|
|
}
|
|
|
|
pub fn reveal_offset(&self, offset: CharOffset, viewport_width: Pixels) -> bool {
|
|
if !self.horizontal_scroll_allowed {
|
|
return false;
|
|
}
|
|
let Some(bounds) = self.relative_character_bounds(offset) else {
|
|
return false;
|
|
};
|
|
let viewport_width = self.viewport_width(viewport_width);
|
|
let mut new_scroll_left = self.scroll_left.get();
|
|
let visible_start = self.scroll_left.get();
|
|
let visible_end = visible_start + viewport_width;
|
|
let character_start = Pixels::new(bounds.origin_x());
|
|
let character_end = Pixels::new(bounds.origin_x() + bounds.width());
|
|
|
|
if character_start - TABLE_SCROLL_REVEAL_MARGIN < visible_start {
|
|
new_scroll_left = (character_start - TABLE_SCROLL_REVEAL_MARGIN).max(Pixels::zero());
|
|
} else if character_end + TABLE_SCROLL_REVEAL_MARGIN > visible_end {
|
|
new_scroll_left =
|
|
(character_end + TABLE_SCROLL_REVEAL_MARGIN - viewport_width).max(Pixels::zero());
|
|
}
|
|
|
|
self.set_scroll_left(new_scroll_left, viewport_width)
|
|
}
|
|
|
|
pub fn character_bounds(&self, offset: CharOffset, table_origin: Vector2F) -> Option<RectF> {
|
|
let bounds = self.relative_character_bounds(offset)?;
|
|
Some(RectF::new(
|
|
table_origin + bounds.origin() - vec2f(self.scroll_left.get().as_f32(), 0.0),
|
|
bounds.size(),
|
|
))
|
|
}
|
|
|
|
pub fn lines(&self) -> LineCount {
|
|
LineCount(1 + self.table.rows.len())
|
|
}
|
|
|
|
/// Maps an x/y coordinate within the table content bounds to the nearest
|
|
/// character offset in the table's flattened content stream.
|
|
pub fn coordinate_to_offset(&self, x: f32, y: f32) -> CharOffset {
|
|
let row = self.row_at_y(y);
|
|
let col = self.col_at_x(x);
|
|
|
|
let Some(cell_range) = self.offset_map.cell_range(row, col) else {
|
|
return CharOffset::zero();
|
|
};
|
|
let Some(cell_offset_map) = self.cell_offset_maps.get(row).and_then(|r| r.get(col)) else {
|
|
return cell_range.start;
|
|
};
|
|
let cell_start = cell_range.start;
|
|
if cell_offset_map.rendered_length() == CharOffset::zero() {
|
|
return cell_start
|
|
+ cell_offset_map
|
|
.rendered_to_source(CharOffset::zero())
|
|
.as_usize();
|
|
}
|
|
|
|
let row_y_start = self.row_y_offsets.get(row).copied().unwrap_or(0.0);
|
|
let col_start_x = self.col_x_offsets.get(col).copied().unwrap_or(0.0);
|
|
let col_width = self
|
|
.column_widths
|
|
.get(col)
|
|
.map(|w| w.as_f32())
|
|
.unwrap_or(0.0);
|
|
|
|
let cell_content_start_x = col_start_x + self.config.style.cell_padding;
|
|
let cell_content_start_y = row_y_start + self.config.style.cell_padding;
|
|
let cell_content_width = (col_width - self.config.style.cell_padding * 2.0).max(0.0);
|
|
|
|
let x_in_cell = (x - cell_content_start_x).max(0.0);
|
|
let y_in_cell = (y - cell_content_start_y).max(0.0);
|
|
|
|
if let Some(cell_layout) = self.cell_layouts.get(row).and_then(|r| r.get(col)) {
|
|
let line_idx = cell_layout.line_at_y_offset(y_in_cell);
|
|
let rendered_offset = cell_layout.char_at_x_in_line(line_idx, x_in_cell);
|
|
return cell_start
|
|
+ cell_offset_map
|
|
.rendered_to_source(rendered_offset)
|
|
.as_usize();
|
|
}
|
|
if x_in_cell <= 0.0 {
|
|
return cell_start
|
|
+ cell_offset_map
|
|
.rendered_to_source(CharOffset::zero())
|
|
.as_usize();
|
|
}
|
|
if x_in_cell >= cell_content_width {
|
|
return cell_start
|
|
+ cell_offset_map
|
|
.rendered_to_source(cell_offset_map.rendered_length())
|
|
.as_usize();
|
|
}
|
|
cell_start
|
|
+ cell_offset_map
|
|
.rendered_to_source(CharOffset::zero())
|
|
.as_usize()
|
|
}
|
|
|
|
/// Returns the row index containing the provided y coordinate.
|
|
fn row_at_y(&self, y: f32) -> usize {
|
|
let num_rows = self.row_y_offsets.len().saturating_sub(1);
|
|
let idx = self.row_y_offsets.partition_point(|&offset| offset <= y);
|
|
idx.saturating_sub(1).min(num_rows.saturating_sub(1))
|
|
}
|
|
|
|
/// Returns the column index containing the provided x coordinate.
|
|
fn col_at_x(&self, x: f32) -> usize {
|
|
let num_cols = self.col_x_offsets.len().saturating_sub(1);
|
|
let idx = self.col_x_offsets.partition_point(|&offset| offset <= x);
|
|
idx.saturating_sub(1).min(num_cols.saturating_sub(1))
|
|
}
|
|
|
|
/// Returns the hyperlink URL at the given character offset within the table,
|
|
/// if the offset falls within a linked fragment.
|
|
pub fn link_at_offset(&self, offset: CharOffset) -> Option<String> {
|
|
let cell_at = self.offset_map.cell_at_offset(offset)?;
|
|
let target = self
|
|
.cell_offset_maps
|
|
.get(cell_at.row)?
|
|
.get(cell_at.col)?
|
|
.source_to_rendered(cell_at.offset_in_cell)
|
|
.as_usize();
|
|
self.cell_links
|
|
.get(cell_at.row)?
|
|
.get(cell_at.col)?
|
|
.iter()
|
|
.find(|link| link.url_range().contains(&target))
|
|
.map(ParsedUrl::link)
|
|
}
|
|
|
|
fn relative_character_bounds(&self, offset: CharOffset) -> Option<RectF> {
|
|
let cell = self.offset_map.cell_at_offset(offset)?;
|
|
let rendered_offset = self
|
|
.cell_offset_maps
|
|
.get(cell.row)?
|
|
.get(cell.col)?
|
|
.source_to_rendered(cell.offset_in_cell);
|
|
let cell_layout = self.cell_layouts.get(cell.row)?.get(cell.col)?;
|
|
let line_idx = cell_layout
|
|
.line_at_char_offset(rendered_offset)
|
|
.unwrap_or(0);
|
|
let line_y = cell_layout
|
|
.line_y_offsets
|
|
.get(line_idx)
|
|
.copied()
|
|
.unwrap_or(0.0);
|
|
let line_height = cell_layout
|
|
.line_heights
|
|
.get(line_idx)
|
|
.copied()
|
|
.unwrap_or(20.0);
|
|
let start_x = cell_layout.x_for_char_in_line(line_idx, rendered_offset.as_usize());
|
|
let end_x = cell_layout.x_for_char_in_line(line_idx, rendered_offset.as_usize() + 1);
|
|
Some(RectF::new(
|
|
self.cell_content_origin(cell.row, cell.col) + vec2f(start_x, line_y),
|
|
vec2f((end_x - start_x).max(1.0), line_height),
|
|
))
|
|
}
|
|
|
|
pub(crate) fn cell_content_origin(&self, row: usize, col: usize) -> Vector2F {
|
|
let col_start_x = self.col_x_offsets.get(col).copied().unwrap_or(0.0);
|
|
let row_start_y = self.row_y_offsets.get(row).copied().unwrap_or(0.0);
|
|
vec2f(
|
|
col_start_x + self.config.style.cell_padding + self.cell_alignment_x_offset(row, col),
|
|
row_start_y + self.config.style.cell_padding,
|
|
)
|
|
}
|
|
|
|
fn cell_alignment_x_offset(&self, row: usize, col: usize) -> f32 {
|
|
let cell_content_width = self
|
|
.column_widths
|
|
.get(col)
|
|
.map(|width| width.as_f32())
|
|
.unwrap_or(0.0)
|
|
- self.config.style.cell_padding * 2.0;
|
|
let cell_content_width = cell_content_width.max(0.0);
|
|
let frame_width = self
|
|
.cell_text_frames
|
|
.get(row)
|
|
.and_then(|row_frames| row_frames.get(col))
|
|
.map(|frame| frame.max_width())
|
|
.unwrap_or(0.0);
|
|
|
|
match self.table.alignments.get(col).copied().unwrap_or_default() {
|
|
TableAlignment::Left => 0.0,
|
|
TableAlignment::Center => (cell_content_width - frame_width).max(0.0) / 2.0,
|
|
TableAlignment::Right => (cell_content_width - frame_width).max(0.0),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl HorizontalRuleConfig {
|
|
pub fn line_size(&self) -> Vector2F {
|
|
vec2f(self.width.as_f32(), self.line_height.as_f32())
|
|
}
|
|
}
|
|
|
|
/// A block's position within a code editor.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum BlockLocation {
|
|
Start,
|
|
Middle,
|
|
End,
|
|
}
|
|
|
|
impl Add for BlockLocation {
|
|
type Output = Self;
|
|
|
|
/// Combine two BlockLocations. This operation is non-commutative; the left-hand side should
|
|
/// come before the right-hand side. Precedence order is `Start` > `End` > `Middle`
|
|
fn add(self, rhs: Self) -> Self::Output {
|
|
match (self, rhs) {
|
|
(Self::Start, _) => Self::Start,
|
|
(_, Self::End) => Self::End,
|
|
(Self::Middle, Self::Middle) => Self::Middle,
|
|
_ => {
|
|
// Out-of-order block locations should not be added together.
|
|
if ChannelState::enable_debug_features() {
|
|
log::error!(
|
|
"Tried to combine block location {self:?} with later location {rhs:?}"
|
|
);
|
|
}
|
|
Self::Middle
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ExpansionType {
|
|
/// Expand the visible section down.
|
|
/// Unhide the top of the hidden section.
|
|
ExpandDown,
|
|
/// Expand the visible section up.
|
|
/// Unhide the bottom of the hidden section.
|
|
ExpandUp,
|
|
/// Unhide the entire hidden section.
|
|
Both,
|
|
}
|
|
|
|
impl ExpansionType {
|
|
pub fn icon(&self) -> Icon {
|
|
match self {
|
|
Self::ExpandDown => Icon::ExpandDown,
|
|
Self::ExpandUp => Icon::ExpandUp,
|
|
Self::Both => Icon::ExpandUpAndDown,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get the gutter buttons that will be displayed for a hidden section
|
|
/// based on the hidden section's location and the number of lines it covers.
|
|
/// Hidden sections at the start and end of a file have one directional button.
|
|
/// Large hidden sections in the middle of a file get two buttons to
|
|
/// expand in either direction.
|
|
/// Small hidden sections in the middle of a file get a single button to
|
|
/// expand the entire hidden section at once.
|
|
pub fn gutter_expansion_button_types(
|
|
block_location: &BlockLocation,
|
|
hidden_range_line_count: usize,
|
|
) -> Vec<ExpansionType> {
|
|
match block_location {
|
|
BlockLocation::Start => vec![ExpansionType::ExpandUp],
|
|
BlockLocation::End => vec![ExpansionType::ExpandDown],
|
|
BlockLocation::Middle => {
|
|
if hidden_range_line_count >= CODE_EDITOR_HIDDEN_SECTION_EXPANSION_LINES {
|
|
vec![ExpansionType::ExpandDown, ExpansionType::ExpandUp]
|
|
} else {
|
|
vec![ExpansionType::Both]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct HiddenBlockConfig {
|
|
line_count: LineCount,
|
|
content_length: CharOffset,
|
|
// The location of the block is set when the hidden section is first laid out,
|
|
// and updated in RenderState.dedupe_hidden_ranges.
|
|
block_location: BlockLocation,
|
|
}
|
|
|
|
impl HiddenBlockConfig {
|
|
pub fn new(
|
|
line_count: LineCount,
|
|
content_length: CharOffset,
|
|
block_location: BlockLocation,
|
|
) -> Self {
|
|
Self {
|
|
line_count,
|
|
content_length,
|
|
block_location,
|
|
}
|
|
}
|
|
|
|
pub fn height(&self) -> Pixels {
|
|
let base_height = HIDDEN_BLOCK_HEIGHT.as_f32();
|
|
(self.display_line_count() as f32 * base_height).into_pixels()
|
|
}
|
|
|
|
fn display_line_count(&self) -> usize {
|
|
self.gutter_button_types().len()
|
|
}
|
|
|
|
fn gutter_button_types(&self) -> Vec<ExpansionType> {
|
|
gutter_expansion_button_types(&self.block_location, self.line_count.as_usize())
|
|
}
|
|
|
|
pub fn line_count(&self) -> LineCount {
|
|
self.line_count
|
|
}
|
|
|
|
pub fn content_length(&self) -> CharOffset {
|
|
self.content_length
|
|
}
|
|
}
|
|
|
|
impl AddAssign for HiddenBlockConfig {
|
|
fn add_assign(&mut self, other: Self) {
|
|
self.line_count += other.line_count;
|
|
self.content_length += other.content_length;
|
|
self.block_location = self.block_location + other.block_location;
|
|
}
|
|
}
|
|
|
|
/// A placeholder element for a cursor that is at the end of the buffer and on a newline.
|
|
/// In this case, we won't have a TextFrame because the new paragraph is empty. But we should
|
|
/// still render the cursor.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Cursor {
|
|
height: Pixels,
|
|
width: Pixels,
|
|
minimum_height: Option<Pixels>,
|
|
spacing: BlockSpacing,
|
|
}
|
|
|
|
impl Cursor {
|
|
pub fn new(
|
|
height: Pixels,
|
|
width: Pixels,
|
|
spacing: BlockSpacing,
|
|
minimum_height: Option<Pixels>,
|
|
) -> Self {
|
|
Self {
|
|
height,
|
|
width,
|
|
spacing,
|
|
minimum_height,
|
|
}
|
|
}
|
|
|
|
pub fn spacing(&self) -> BlockSpacing {
|
|
self.spacing
|
|
}
|
|
|
|
/// The size of this cursor, in pixels.
|
|
pub fn size(&self) -> Vector2F {
|
|
vec2f(self.width.as_f32(), self.height.as_f32())
|
|
}
|
|
}
|
|
|
|
impl RenderState {
|
|
/// Create a new `RenderState` model (Pixels/GUI mode).
|
|
/// The initial content will be a single **trailing newline**.
|
|
pub fn new(
|
|
styles: RichTextStyles,
|
|
lazy_layout: bool,
|
|
hidden_lines: Option<ModelHandle<HiddenLinesModel>>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> Self {
|
|
let (element_tx, element_rx) = async_channel::unbounded();
|
|
ctx.spawn_stream_local(element_rx, Self::apply_element_update, |_, _| {});
|
|
|
|
let (layout_tx, layout_rx) = async_channel::unbounded();
|
|
ctx.spawn_stream_local(layout_rx, Self::handle_layout_action, |_, _| {});
|
|
|
|
Self::new_internal(
|
|
ctx.model_id(),
|
|
element_tx,
|
|
layout_tx,
|
|
styles,
|
|
lazy_layout,
|
|
Pixels::zero(),
|
|
Pixels::zero(),
|
|
hidden_lines,
|
|
LayoutMode::Pixels,
|
|
)
|
|
}
|
|
|
|
/// Create a new `RenderState` in TUI char-cell mode.
|
|
///
|
|
/// In this mode, soft-wrap layout uses character-count arithmetic instead of font shaping.
|
|
/// The async font pipeline is still wired up (so `LayoutAction` messages sent by other code
|
|
/// don't cause panics), but `BufferEdit` actions are no-ops — the caller must drive layout
|
|
/// updates via [`Self::update_char_cell_text`].
|
|
///
|
|
/// `styles` is stored on the struct for API compatibility but is **not used** for rendering
|
|
/// in CharCell mode. Callers (e.g. `warp_tui`) should supply a minimal stub.
|
|
pub fn new_tui(
|
|
terminal_width: u16,
|
|
styles: RichTextStyles,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> Self {
|
|
let (element_tx, element_rx) = async_channel::unbounded();
|
|
ctx.spawn_stream_local(element_rx, Self::apply_element_update, |_, _| {});
|
|
|
|
let (layout_tx, layout_rx) = async_channel::unbounded();
|
|
ctx.spawn_stream_local(layout_rx, Self::handle_layout_action, |_, _| {});
|
|
|
|
// In CharCell mode the SumTree<BlockItem> is never queried for layout, so
|
|
// we create an empty tree rather than the usual style-dependent trailing newline.
|
|
let entity_id = ctx.model_id();
|
|
let (viewport_width, viewport_height) = (Pixels::zero(), Pixels::zero());
|
|
Self {
|
|
styles,
|
|
show_final_trailing_newline_when_non_empty: false,
|
|
has_final_trailing_newline: Cell::new(false),
|
|
viewport: ViewportState::new(viewport_width, viewport_height),
|
|
selections: Default::default(),
|
|
decorations: Default::default(),
|
|
content: RefCell::new(SumTree::new()),
|
|
element_tx,
|
|
layout_tx,
|
|
width_setting: Default::default(),
|
|
saved_positions: SavedPositions::new(entity_id),
|
|
buffer_version: RefCell::new(Default::default()),
|
|
lazy_layout: false,
|
|
pending_edits: Mutex::new(Vec::new()),
|
|
#[cfg(any(test, feature = "test-util"))]
|
|
outstanding_layouts: Default::default(),
|
|
pending_selection_change: Mutex::new(None),
|
|
layout_options: Default::default(),
|
|
document_path: None,
|
|
hidden_lines: None,
|
|
layout_mode: LayoutMode::CharCell(CharCellState::new(terminal_width)),
|
|
}
|
|
}
|
|
|
|
/// Create a new `RenderState` with the given configuration.
|
|
/// The initial content will be a single **trailing newline**.
|
|
#[cfg(test)]
|
|
pub fn new_for_test(
|
|
styles: RichTextStyles,
|
|
viewport_width: Pixels,
|
|
viewport_height: Pixels,
|
|
) -> Self {
|
|
let (element_tx, _) = async_channel::unbounded();
|
|
let (layout_tx, _) = async_channel::unbounded();
|
|
Self::new_internal(
|
|
EntityId::new(),
|
|
element_tx,
|
|
layout_tx,
|
|
styles,
|
|
false,
|
|
viewport_width,
|
|
viewport_height,
|
|
None,
|
|
LayoutMode::Pixels,
|
|
)
|
|
}
|
|
|
|
/// Create a new `RenderState` with the given configuration.
|
|
/// The initial content will be a single **trailing newline**.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn new_internal(
|
|
entity_id: EntityId,
|
|
element_tx: async_channel::Sender<ElementUpdate>,
|
|
layout_tx: async_channel::Sender<LayoutAction>,
|
|
styles: RichTextStyles,
|
|
lazy_layout: bool,
|
|
viewport_width: Pixels,
|
|
viewport_height: Pixels,
|
|
hidden_lines: Option<ModelHandle<HiddenLinesModel>>,
|
|
layout_mode: LayoutMode,
|
|
) -> Self {
|
|
let content = SumTree::from_item(Self::final_trailing_newline_cursor(&styles));
|
|
Self {
|
|
styles,
|
|
show_final_trailing_newline_when_non_empty: true,
|
|
has_final_trailing_newline: Cell::new(true),
|
|
viewport: ViewportState::new(viewport_width, viewport_height),
|
|
selections: Default::default(),
|
|
decorations: Default::default(),
|
|
content: RefCell::new(content),
|
|
element_tx,
|
|
layout_tx,
|
|
width_setting: Default::default(),
|
|
saved_positions: SavedPositions::new(entity_id),
|
|
buffer_version: RefCell::new(Default::default()),
|
|
lazy_layout,
|
|
pending_edits: Mutex::new(Vec::new()),
|
|
#[cfg(any(test, feature = "test-util"))]
|
|
outstanding_layouts: Default::default(),
|
|
pending_selection_change: Mutex::new(None),
|
|
layout_options: Default::default(),
|
|
document_path: None,
|
|
hidden_lines,
|
|
layout_mode,
|
|
}
|
|
}
|
|
|
|
/// Returns the char-cell layout state, or `None` in pixel (GUI) mode.
|
|
///
|
|
/// All char-cell queries and mutations go through this accessor and the
|
|
/// methods on [`CharCellState`], so they are only reachable when the model is
|
|
/// actually in char-cell mode — there is no implicit "CharCell-only" contract
|
|
/// on `RenderState` for callers to violate. The width stored there is the
|
|
/// single source of truth for the TUI input's width: navigation and scroll
|
|
/// read it at event time, so callers should read it via
|
|
/// [`CharCellState::terminal_width`] rather than caching a copy.
|
|
pub fn char_cell(&self) -> Option<&CharCellState> {
|
|
match &self.layout_mode {
|
|
LayoutMode::CharCell(cc) => Some(cc),
|
|
LayoutMode::Pixels => None,
|
|
}
|
|
}
|
|
|
|
fn final_trailing_newline_cursor(styles: &RichTextStyles) -> BlockItem {
|
|
BlockItem::TrailingNewLine(Cursor::new(
|
|
styles.base_line_height(),
|
|
styles.cursor_width.into_pixels(),
|
|
styles
|
|
.block_spacings
|
|
.from_block_style(&BufferBlockStyle::PlainText),
|
|
styles.minimum_paragraph_height,
|
|
))
|
|
}
|
|
|
|
fn should_show_final_trailing_newline(&self, tree_is_empty: bool) -> bool {
|
|
self.show_final_trailing_newline_when_non_empty || tree_is_empty
|
|
}
|
|
|
|
fn tree_ends_with_trailing_newline(tree: &SumTree<BlockItem>) -> bool {
|
|
let mut cursor = tree.cursor::<(), ()>();
|
|
cursor.descend_to_last_item(tree);
|
|
cursor.item().is_some_and(|item| item.is_trailing_newline())
|
|
}
|
|
|
|
fn remove_final_trailing_newline_if_present(&mut self) {
|
|
if !self.has_final_trailing_newline.get() {
|
|
return;
|
|
}
|
|
|
|
let content = self.content.get_mut();
|
|
let new_tree = {
|
|
let mut cursor = content.cursor::<CharOffset, ()>();
|
|
cursor.descend_to_last_item(content);
|
|
|
|
if !cursor.item().is_some_and(BlockItem::is_trailing_newline)
|
|
|| cursor.prev_item().is_none()
|
|
{
|
|
return;
|
|
}
|
|
|
|
let last_item_start = *cursor.seek_position();
|
|
let mut slice_cursor = content.cursor::<CharOffset, ()>();
|
|
slice_cursor.slice(&last_item_start, SeekBias::Left)
|
|
};
|
|
|
|
*content = new_tree;
|
|
self.has_final_trailing_newline.set(false);
|
|
}
|
|
|
|
fn add_final_trailing_newline_if_missing(&mut self) {
|
|
if self.has_final_trailing_newline.get() {
|
|
return;
|
|
}
|
|
|
|
self.content
|
|
.get_mut()
|
|
.push(Self::final_trailing_newline_cursor(&self.styles));
|
|
self.has_final_trailing_newline.set(true);
|
|
}
|
|
|
|
/// Returns reference to the underlying content tree.
|
|
pub fn content(&self) -> RenderContentTreeRef<'_> {
|
|
RenderContentTreeRef(self.content.borrow())
|
|
}
|
|
|
|
pub fn with_width_setting(mut self, setting: WidthSetting) -> Self {
|
|
self.width_setting = setting;
|
|
self
|
|
}
|
|
|
|
/// Whether the surrounding container for this render state already provides horizontal
|
|
/// scrolling over its full content area. Blocks that would otherwise introduce a nested
|
|
/// horizontal scroll (for example, wide Markdown tables) should render at full intrinsic
|
|
/// width in that case.
|
|
pub fn container_scrolls_horizontally(&self) -> bool {
|
|
matches!(self.width_setting, WidthSetting::InfiniteWidth)
|
|
}
|
|
|
|
pub fn layout_options(&self) -> RenderLayoutOptions {
|
|
self.layout_options.clone()
|
|
}
|
|
|
|
pub fn set_render_mermaid_diagrams(&mut self, render_mermaid_diagrams: bool) -> bool {
|
|
if self.layout_options.render_mermaid_diagrams == render_mermaid_diagrams {
|
|
return false;
|
|
}
|
|
|
|
self.layout_options.render_mermaid_diagrams = render_mermaid_diagrams;
|
|
true
|
|
}
|
|
|
|
pub fn set_mermaid_render_offsets(&mut self, offsets: HashSet<CharOffset>) -> bool {
|
|
if self.layout_options.mermaid_render_offsets == offsets {
|
|
return false;
|
|
}
|
|
self.layout_options.mermaid_render_offsets = offsets;
|
|
true
|
|
}
|
|
|
|
pub fn set_show_final_trailing_newline_when_non_empty(&mut self, show: bool) {
|
|
if self.show_final_trailing_newline_when_non_empty == show {
|
|
return;
|
|
}
|
|
|
|
self.show_final_trailing_newline_when_non_empty = show;
|
|
|
|
if show {
|
|
self.add_final_trailing_newline_if_missing();
|
|
} else {
|
|
self.remove_final_trailing_newline_if_present();
|
|
}
|
|
|
|
self.update_content_sizing();
|
|
}
|
|
|
|
pub fn max_line(&self) -> LineCount {
|
|
if let LayoutMode::CharCell(ref cc) = self.layout_mode {
|
|
return char_cell_max_line(
|
|
&cc.line_starts.borrow(),
|
|
&cc.char_widths.borrow(),
|
|
cc.terminal_width.get(),
|
|
);
|
|
}
|
|
self.content.borrow().summary().lines
|
|
}
|
|
|
|
/// The complete height of all laid-out content.
|
|
pub fn height(&self) -> Pixels {
|
|
(self.content.borrow().summary().height as f32).into_pixels()
|
|
}
|
|
|
|
pub fn width(&self) -> Pixels {
|
|
self.content.borrow().summary().width
|
|
}
|
|
|
|
pub fn next_render_buffer_version(&self) -> Option<BufferVersion> {
|
|
self.buffer_version.borrow().next_render_version
|
|
}
|
|
|
|
/// The number of blocks in the render model.
|
|
#[cfg(any(test, feature = "test-util"))]
|
|
pub fn blocks(&self) -> usize {
|
|
self.content().block_items().count()
|
|
}
|
|
|
|
pub fn markdown_table_count(&self) -> usize {
|
|
self.content()
|
|
.block_items()
|
|
.filter(|item| matches!(item, BlockItem::Table(_)))
|
|
.count()
|
|
}
|
|
|
|
pub fn is_entire_range_of_type(
|
|
&self,
|
|
range: &Range<CharOffset>,
|
|
matches_type: impl FnMut(&BlockItem) -> bool,
|
|
) -> bool {
|
|
self.content().is_entire_range_of_type(range, matches_type)
|
|
}
|
|
|
|
/// The max offset in the laid out content. Note that we minus one in the end
|
|
/// here because have a dummy placeholder offset for the end of the buffer.
|
|
pub fn max_offset(&self) -> CharOffset {
|
|
self.content
|
|
.borrow()
|
|
.summary()
|
|
.content_length
|
|
.saturating_sub(&CharOffset::from(1))
|
|
}
|
|
|
|
/// Returns the vertical viewport offset of the location.
|
|
pub fn vertical_offset_at_render_location(
|
|
&self,
|
|
location: RenderLineLocation,
|
|
) -> Option<Pixels> {
|
|
let content = self.content.borrow();
|
|
let mut cursor = content.cursor::<LineCount, LayoutSummary>();
|
|
|
|
Self::move_cursor_to_location(&mut cursor, location);
|
|
|
|
Some(cursor.positioned_item()?.start_y_offset - self.viewport().scroll_top())
|
|
}
|
|
|
|
fn move_cursor_to_location<'a>(
|
|
cursor: &mut sum_tree::Cursor<'a, BlockItem, LineCount, LayoutSummary>,
|
|
location: RenderLineLocation,
|
|
) {
|
|
match location {
|
|
RenderLineLocation::Current(_) => {
|
|
cursor.seek_clamped(&location.line_count(), SeekBias::Right)
|
|
}
|
|
RenderLineLocation::Temporary {
|
|
index_from_at_line, ..
|
|
} => {
|
|
cursor.seek_clamped(&location.line_count(), SeekBias::Left);
|
|
if location.line_count() > LineCount(0) {
|
|
cursor.next();
|
|
}
|
|
// Temporary blocks are 0 indexed.
|
|
for _ in 0..index_from_at_line {
|
|
cursor.next();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Given a line range and the viewport max width, returns the viewport item and block for that range.
|
|
pub fn blocks_in_line_range(
|
|
&self,
|
|
line_range: Range<RenderLineLocation>,
|
|
max_width: Pixels,
|
|
) -> Vec<(ViewportItem, BlockItem)> {
|
|
let content = self.content.borrow();
|
|
let mut cursor = content.cursor::<LineCount, LayoutSummary>();
|
|
Self::move_cursor_to_location(&mut cursor, line_range.start);
|
|
|
|
let mut blocks = Vec::new();
|
|
let mut previous_line = line_range.start.line_count();
|
|
let mut index_within_line = if let RenderLineLocation::Temporary {
|
|
index_from_at_line,
|
|
..
|
|
} = line_range.start
|
|
{
|
|
index_from_at_line
|
|
} else {
|
|
0
|
|
};
|
|
loop {
|
|
let Some(item) = cursor.positioned_item() else {
|
|
break;
|
|
};
|
|
if item.start_line != previous_line {
|
|
index_within_line = 0;
|
|
} else {
|
|
index_within_line += 1;
|
|
}
|
|
|
|
previous_line = item.start_line;
|
|
|
|
let spacing = item.item.spacing();
|
|
let content_width = max_width - spacing.x_axis_offset();
|
|
let viewport_item = ViewportItem {
|
|
viewport_offset: Pixels::zero(),
|
|
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,
|
|
};
|
|
blocks.push((viewport_item, item.item.clone()));
|
|
|
|
// For iterating on current line ranges, once we detect that the current item matches / or exceeds the end line,
|
|
// we can break out of the loop. For temporary line ranges, we should check if 1) the current item hasn't past the at line
|
|
// temporary block is anchored to 2) we haven't exceeded the index.
|
|
match line_range.end {
|
|
RenderLineLocation::Current(end_line) => {
|
|
if item.end_line() >= end_line {
|
|
break;
|
|
}
|
|
}
|
|
RenderLineLocation::Temporary {
|
|
index_from_at_line: index,
|
|
at_line,
|
|
} => {
|
|
if item.start_line > at_line
|
|
|| (item.start_line == at_line && index_within_line >= index)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
cursor.next();
|
|
}
|
|
|
|
blocks
|
|
}
|
|
|
|
/// Baseline styles applied to rich text when rendering.
|
|
pub fn styles(&self) -> &RichTextStyles {
|
|
&self.styles
|
|
}
|
|
|
|
/// Get the document path for resolving relative paths (e.g. images).
|
|
pub fn document_path(&self) -> Option<&std::path::Path> {
|
|
self.document_path.as_deref()
|
|
}
|
|
|
|
/// Set the document path for resolving relative paths (e.g. images).
|
|
pub fn set_document_path(&mut self, path: Option<std::path::PathBuf>) {
|
|
self.document_path = path;
|
|
}
|
|
|
|
/// Update the styles used to render text. Because the render model does not directly reference
|
|
/// the content model, the caller is responsible for updating the layout with the new styles.
|
|
///
|
|
/// This returns whether a new layout is needed.
|
|
pub fn update_styles(&mut self, new_styles: RichTextStyles) -> StyleUpdateAction {
|
|
let styles_changed = new_styles != self.styles;
|
|
if styles_changed {
|
|
let action = if self.styles.requires_relayout(&new_styles) {
|
|
StyleUpdateAction::Relayout
|
|
} else {
|
|
StyleUpdateAction::Repaint
|
|
};
|
|
|
|
self.styles = new_styles;
|
|
return action;
|
|
}
|
|
StyleUpdateAction::None
|
|
}
|
|
|
|
/// Handle to obtain saved position IDs within the rendered text.
|
|
pub fn saved_positions(&self) -> &SavedPositions {
|
|
&self.saved_positions
|
|
}
|
|
|
|
/// Returns the current viewport state.
|
|
pub fn viewport(&self) -> &ViewportState {
|
|
&self.viewport
|
|
}
|
|
|
|
/// Return the character offset ranges of items in the viewport. Note that the start / end offset
|
|
/// could be out of the viewport if the item is only partially visible.
|
|
pub fn viewport_charoffset_range(&self) -> RangeSet<CharOffset> {
|
|
let mut range_set = RangeSet::new();
|
|
let content = self.content.borrow();
|
|
let mut cursor = content.cursor::<Height, LayoutSummary>();
|
|
cursor.seek_clamped(&self.viewport.scroll_top().into(), SeekBias::Left);
|
|
|
|
let viewport_end_height = self.viewport.scroll_top() + self.viewport().height();
|
|
|
|
// Track the current range being built
|
|
let mut current_range_start: Option<CharOffset> = None;
|
|
|
|
// Iterate through all items in the viewport
|
|
loop {
|
|
let item = cursor.positioned_item();
|
|
if let Some(positioned_item) = item {
|
|
// Stop if we've gone past the viewport
|
|
if positioned_item.start_y_offset > viewport_end_height {
|
|
break;
|
|
}
|
|
|
|
let item_start = positioned_item.start_char_offset;
|
|
|
|
if positioned_item.item.is_hidden() {
|
|
// This is a hidden item and we're not including hidden items
|
|
if let Some(range_start) = current_range_start.take() {
|
|
// Close the current range before this hidden item
|
|
if range_start < item_start {
|
|
range_set.insert(range_start..item_start);
|
|
}
|
|
}
|
|
} else {
|
|
// If we don't have a current range, start one
|
|
if current_range_start.is_none() {
|
|
current_range_start = Some(item_start);
|
|
}
|
|
}
|
|
|
|
// Move to the next item
|
|
cursor.next();
|
|
} else {
|
|
// No more items
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If we ended with an open range, close it
|
|
if let Some(range_start) = current_range_start
|
|
&& range_start < self.max_offset()
|
|
{
|
|
range_set.insert(range_start..self.max_offset());
|
|
}
|
|
|
|
range_set
|
|
}
|
|
|
|
/// Stores the viewport size that was calculated during layout back into
|
|
/// the model.
|
|
///
|
|
/// See [`ViewportState::set_size`].
|
|
pub fn set_viewport_size(&mut self, size_info: SizeInfo, ctx: &mut ModelContext<Self>) {
|
|
let height_changed = size_info
|
|
.viewport_size
|
|
.y()
|
|
.approx_ne(self.viewport.height().as_f32(), UNIT_MARGIN);
|
|
|
|
self.viewport
|
|
.set_size(size_info.viewport_size, self.width(), self.height());
|
|
|
|
// TODO(CLD-85): re-layout according to the high-level design (async, debounced, avoid
|
|
// where possible).
|
|
// In order to do this, we need to:
|
|
// - Extract debouncing logic from the main app crate
|
|
// - Support text layout outside the Element lifecycle
|
|
if size_info.needs_layout {
|
|
ctx.emit(RenderEvent::NeedsResize);
|
|
}
|
|
|
|
// Autoscroll when viewport height changes on mobile (e.g., keyboard appears)
|
|
if cfg!(target_family = "wasm") && height_changed {
|
|
self.request_autoscroll();
|
|
}
|
|
}
|
|
|
|
/// Scroll the viewport by the given number of lines. Even with precise
|
|
/// trackpad scrolling, all scroll events are reported in lines.
|
|
pub fn scroll(&mut self, delta: Pixels, ctx: &mut ModelContext<Self>) {
|
|
if self.viewport.scroll(delta, self.height()) {
|
|
ctx.notify();
|
|
}
|
|
}
|
|
|
|
pub fn scroll_horizontal(&mut self, delta: Pixels, ctx: &mut ModelContext<Self>) {
|
|
if self.viewport.scroll_horizontally(delta, self.width()) {
|
|
ctx.notify();
|
|
}
|
|
}
|
|
|
|
/// Scroll to a normalized position, as returned by [`Self::snapshot_scroll_position`].
|
|
///
|
|
/// This will be serialized with respect to layout changes.
|
|
pub fn scroll_to(&mut self, position: ScrollPositionSnapshot) {
|
|
self.submit_layout_action(LayoutAction::ScrollTo(position))
|
|
}
|
|
|
|
/// Snapshot the current scroll position.
|
|
pub fn snapshot_scroll_position(&self) -> ScrollPositionSnapshot {
|
|
ScrollPositionSnapshot::from_scroll_top(self)
|
|
}
|
|
|
|
pub fn scroll_data_horizontal(&self) -> ScrollData {
|
|
let mut visible_px = self.viewport.width();
|
|
let total_size = self.width();
|
|
if visible_px.approx_eq(total_size, UNIT_MARGIN) {
|
|
// This is a hack copied from the BlockListElement. Due to floating-point
|
|
// errors, total_size and visible_px may be slightly different,
|
|
// even if they should be the same. In that case, set them to be
|
|
// equal so that a useless scroll bar isn't shown.
|
|
visible_px = total_size;
|
|
}
|
|
|
|
ScrollData {
|
|
scroll_start: self.viewport.scroll_left(),
|
|
visible_px,
|
|
total_size,
|
|
}
|
|
}
|
|
|
|
pub fn set_decorations_after_layout(
|
|
&mut self,
|
|
mut decoration_update: UpdateDecorationAfterLayout,
|
|
) {
|
|
decoration_update.sort();
|
|
self.submit_layout_action(LayoutAction::DecorationChanged(decoration_update));
|
|
}
|
|
|
|
pub fn set_text_decorations(
|
|
&mut self,
|
|
decorations: impl Into<Vec<Decoration>>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.decorations.text = decorations.into();
|
|
// Generally, callers will provide already-sorted decorations, in which case the current
|
|
// Rust sorting algorithm aims for linear time.
|
|
self.decorations
|
|
.text
|
|
.sort_unstable_by_key(|decoration| decoration.end);
|
|
ctx.notify();
|
|
}
|
|
|
|
/// The current render-time decorations, sorted by end offset.
|
|
pub fn decorations(&self) -> &RenderDecoration {
|
|
&self.decorations
|
|
}
|
|
|
|
/// Update the render model's selection. To avoid flicker, this will not be rendered until the
|
|
/// next round of layout completes.
|
|
pub fn update_selection(
|
|
&mut self,
|
|
new_selection: RenderedSelectionSet,
|
|
buffer_version: BufferVersion,
|
|
) {
|
|
self.submit_layout_action(LayoutAction::SelectionChanged {
|
|
selections: new_selection,
|
|
buffer_version,
|
|
});
|
|
}
|
|
|
|
/// The current rendered selection state. This should track the content-level
|
|
/// selection.
|
|
pub fn selections<'a>(&'a self) -> Ref<'a, RenderedSelectionSet> {
|
|
self.selections.borrow()
|
|
}
|
|
|
|
/// Check if the given offset is within any of the current selections.
|
|
pub fn offset_in_active_selection(&self, offset: CharOffset) -> bool {
|
|
self.selections()
|
|
.iter()
|
|
.any(|selection| offset > selection.start() && selection.end() + 1 > offset)
|
|
}
|
|
|
|
/// Check if the given offset any of the current selection heads.
|
|
pub fn is_selection_head(&self, offset: CharOffset) -> bool {
|
|
self.selections()
|
|
.iter()
|
|
.any(|selection| selection.head == offset)
|
|
}
|
|
|
|
/// Request an autoscroll using the given mode after text layout completes.
|
|
pub fn request_autoscroll_to(&mut self, mode: AutoScrollMode) {
|
|
self.submit_layout_action(LayoutAction::Autoscroll { mode });
|
|
}
|
|
|
|
/// Request an autoscroll to position the selection head within the viewport
|
|
/// after text layout completes.
|
|
pub fn request_autoscroll(&mut self) {
|
|
self.submit_layout_action(LayoutAction::Autoscroll {
|
|
mode: AutoScrollMode::ScrollToActiveSelections {
|
|
vertical_only: false,
|
|
},
|
|
});
|
|
}
|
|
|
|
/// Request an autoscroll to position the selection head within the viewport
|
|
/// after text layout completes, but only vertically (no horizontal autoscroll).
|
|
pub fn request_vertical_autoscroll(&mut self) {
|
|
self.submit_layout_action(LayoutAction::Autoscroll {
|
|
mode: AutoScrollMode::ScrollToActiveSelections {
|
|
vertical_only: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
/// Request to autoscroll to a scroll top of exact character offset with a pixel delta.
|
|
pub fn request_autoscroll_to_exact_vertical(
|
|
&mut self,
|
|
character_offset: CharOffset,
|
|
pixel_delta: Pixels,
|
|
) {
|
|
self.submit_layout_action(LayoutAction::Autoscroll {
|
|
mode: AutoScrollMode::ScrollToExactVertical {
|
|
character_offset,
|
|
pixel_delta,
|
|
},
|
|
});
|
|
}
|
|
|
|
/// Submit a layout update to be processed asynchronously. This avoids mutable-borrow issues.
|
|
pub(crate) fn submit_element_update(&self, update: ElementUpdate) {
|
|
if let Err(err) = self.element_tx.try_send(update) {
|
|
// We know the RenderState model still exists at this point, so it _should_ still be
|
|
// processing updates.
|
|
log::debug!("Error submitting layout update: {err}");
|
|
}
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-util"))]
|
|
pub fn layout_complete(&self) -> impl std::future::Future<Output = ()> + use<> {
|
|
let outstanding_layouts = self.outstanding_layouts.clone();
|
|
async move {
|
|
while outstanding_layouts.load(std::sync::atomic::Ordering::SeqCst) > 0 {
|
|
futures_lite::future::yield_now().await;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_layout_action(&mut self, action: LayoutAction, ctx: &mut ModelContext<Self>) {
|
|
match action {
|
|
LayoutAction::SelectionChanged {
|
|
selections,
|
|
buffer_version,
|
|
} => {
|
|
if selections != *self.selections() {
|
|
// If the version for the selection is newer than the last version we've rendered, then mark it as pending and apply it on our next layout
|
|
// Otherwise immediately apply it.
|
|
let active_version = self.buffer_version.borrow().last_rendered_version;
|
|
if active_version.is_some_and(|v| v < buffer_version) {
|
|
*self.pending_selection_change.lock() = Some(PendingSelectionUpdate {
|
|
selection: selections,
|
|
buffer_version,
|
|
});
|
|
} else {
|
|
*self.selections.borrow_mut() = selections;
|
|
ctx.notify();
|
|
}
|
|
}
|
|
}
|
|
LayoutAction::DecorationChanged(decoration) => match decoration {
|
|
UpdateDecorationAfterLayout::Line(decoration) => {
|
|
if decoration != self.decorations.line {
|
|
self.decorations.line = decoration;
|
|
ctx.notify();
|
|
}
|
|
}
|
|
UpdateDecorationAfterLayout::LineAndText { line, text } => {
|
|
let mut changed = false;
|
|
|
|
if line != self.decorations.line {
|
|
self.decorations.line = line;
|
|
changed = true;
|
|
}
|
|
|
|
if text != self.decorations.text {
|
|
self.decorations.text = text;
|
|
changed = true;
|
|
}
|
|
|
|
if changed {
|
|
ctx.notify();
|
|
}
|
|
}
|
|
},
|
|
LayoutAction::Autoscroll { mode } => {
|
|
self.autoscroll(mode, ctx);
|
|
}
|
|
LayoutAction::ScrollTo(position) => {
|
|
if self
|
|
.viewport
|
|
.scroll_to(position.to_scroll_top(self), self.height())
|
|
{
|
|
ctx.notify();
|
|
}
|
|
}
|
|
LayoutAction::LayoutTemporaryBlock(blocks) => {
|
|
// CharCell mode skips font layout for temporary blocks.
|
|
//
|
|
// Temporary blocks represent interleaved deleted/replaced lines in diff
|
|
// views (only created by `CodeEditorModel::refresh_diff_state` for code
|
|
// review). They are not needed for M1 (basic TUI text input).
|
|
//
|
|
// TODO(TUI-diff): When a TUI diff/code-review view is built, add:
|
|
// `temporary_blocks: Vec<CharCellTemporaryBlock>` to `CharCellState`
|
|
// with fields: `{ content: String, insert_before: LineCount,
|
|
// line_decoration: Option<ColorU>, inline_decorations: Vec<(Range<usize>, ColorU)> }`.
|
|
// Populate it here instead of no-op'ing. The `TuiInputView` render loop
|
|
// then merges them as extra `TuiText` rows with `Style::bg(color)`,
|
|
// interleaved at their `insert_before` line positions. No SumTree or
|
|
// font-shaping changes needed — the GUI path is unaffected.
|
|
if matches!(self.layout_mode, LayoutMode::CharCell(_)) {
|
|
ctx.emit(RenderEvent::LayoutUpdated);
|
|
ctx.notify();
|
|
return;
|
|
}
|
|
|
|
// If we are performing layout lazily, push the temporary blocks to the pending edits queue which is flushed
|
|
// at editor element layout time
|
|
if self.lazy_layout {
|
|
self.pending_edits
|
|
.lock()
|
|
.push(PendingLayout::TemporaryBlocks(blocks));
|
|
} else {
|
|
self.layout_temporary_blocks(blocks, ctx);
|
|
self.update_content_sizing();
|
|
}
|
|
|
|
ctx.emit(RenderEvent::LayoutUpdated);
|
|
ctx.notify();
|
|
}
|
|
LayoutAction::BufferEdit {
|
|
delta: _,
|
|
buffer_version,
|
|
} if matches!(self.layout_mode, LayoutMode::CharCell(_)) => {
|
|
// CharCell mode skips font shaping. CodeEditorModel drives char-cell layout
|
|
// synchronously via CharCellState::update_text after each buffer edit.
|
|
self.buffer_version.borrow_mut().next_render_version = Some(buffer_version);
|
|
ctx.emit(RenderEvent::LayoutUpdated);
|
|
ctx.notify();
|
|
}
|
|
LayoutAction::BufferEdit {
|
|
delta,
|
|
buffer_version,
|
|
} => {
|
|
// Materialize the hidden ranges based on the version.
|
|
let hidden_ranges = self
|
|
.hidden_lines
|
|
.as_ref()
|
|
.map(|hl| hl.as_ref(ctx).hidden_ranges_at_version(buffer_version));
|
|
|
|
// If we are performing layout lazily, push the delta to the pending edits queue which is flushed
|
|
// at editor element layout time.
|
|
if self.lazy_layout {
|
|
self.pending_edits.lock().push(PendingLayout::Edit {
|
|
delta,
|
|
hidden_ranges,
|
|
});
|
|
} else {
|
|
// If there were pending edits, we need to re-render so that RenderableBlocks
|
|
// are properly laid out with the new set of BlockItems.
|
|
self.layout_edit_delta(delta, hidden_ranges, ctx);
|
|
self.update_content_sizing();
|
|
|
|
self.flush_pending_selection_update(Some(buffer_version));
|
|
}
|
|
self.buffer_version.borrow_mut().next_render_version = Some(buffer_version);
|
|
ctx.emit(RenderEvent::LayoutUpdated);
|
|
ctx.notify();
|
|
}
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-util"))]
|
|
self.outstanding_layouts
|
|
.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
|
|
}
|
|
|
|
/// Given the content version that we've just laid out and rendered, flush any pending selection for that content version
|
|
fn flush_pending_selection_update(&self, incoming_buffer_version: Option<BufferVersion>) {
|
|
let mut pending_selection = self.pending_selection_change.lock();
|
|
if let Some(selection) = &*pending_selection
|
|
&& incoming_buffer_version
|
|
.map(|v| v >= selection.buffer_version)
|
|
.unwrap_or(true)
|
|
{
|
|
*self.selections.borrow_mut() = selection.selection.clone();
|
|
pending_selection.take();
|
|
}
|
|
}
|
|
|
|
fn layout_temporary_blocks(&self, blocks: Vec<TemporaryBlock>, app: &AppContext) {
|
|
let layout_cache = LayoutCache::new();
|
|
let layout_context = self.layout_context(&layout_cache, app);
|
|
let laid_out_blocks = layout_temporary_blocks(blocks, &layout_context);
|
|
self.reset_temporary_block(laid_out_blocks);
|
|
}
|
|
|
|
pub fn layout_edit_delta(
|
|
&self,
|
|
delta: EditDelta,
|
|
hidden_ranges: Option<RangeSet<CharOffset>>,
|
|
app: &AppContext,
|
|
) {
|
|
let layout_cache = LayoutCache::new();
|
|
let layout_context = self.layout_context(&layout_cache, app);
|
|
let laid_out_edit = delta.layout_delta(
|
|
&layout_context,
|
|
self.document_path.as_deref(),
|
|
&self.layout_options,
|
|
hidden_ranges.clone(),
|
|
app,
|
|
);
|
|
self.layout_pending_edit(laid_out_edit, hidden_ranges);
|
|
}
|
|
|
|
/// Construct a throwaway layout cache. We only lay out modified text, so in effect,
|
|
/// the entire RenderState is a cache.
|
|
fn layout_context<'a>(
|
|
&'a self,
|
|
layout_cache: &'a LayoutCache,
|
|
ctx: &'a AppContext,
|
|
) -> TextLayout<'a> {
|
|
TextLayout::new(
|
|
layout_cache,
|
|
ctx.font_cache().text_layout_system(),
|
|
&self.styles,
|
|
match self.width_setting {
|
|
WidthSetting::FitViewport => self.viewport.width().as_f32(),
|
|
WidthSetting::InfiniteWidth => f32::MAX,
|
|
},
|
|
)
|
|
.with_container_scrolls_horizontally(self.container_scrolls_horizontally())
|
|
}
|
|
|
|
/// If we are performing layout lazily, call this to flush the pending edits and selection changes
|
|
/// at layout time in the UI framework element cycle.
|
|
pub fn try_layout_pending_edits(&self, app: &AppContext) -> bool {
|
|
let mut pending_edits = self.pending_edits.lock();
|
|
let last_rendered_version = self.buffer_version.borrow_mut().start_layout();
|
|
let pending_edits_flushed = self.lazy_layout && !pending_edits.is_empty();
|
|
|
|
if pending_edits_flushed {
|
|
for edit in mem::take(&mut *pending_edits) {
|
|
match edit {
|
|
PendingLayout::Edit {
|
|
delta,
|
|
hidden_ranges,
|
|
} => {
|
|
self.layout_edit_delta(delta, hidden_ranges, app);
|
|
}
|
|
PendingLayout::TemporaryBlocks(blocks) => {
|
|
self.layout_temporary_blocks(blocks, app);
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
// Flush the pending selection changes.
|
|
self.flush_pending_selection_update(last_rendered_version);
|
|
pending_edits_flushed
|
|
}
|
|
|
|
/// Updates the model with the results of laying out its element.
|
|
fn apply_element_update(&mut self, update: ElementUpdate, ctx: &mut ModelContext<Self>) {
|
|
// Clear up the remaining pending edits state after layout is completed. Also make sure we have the updated
|
|
// content sizing.
|
|
if self.lazy_layout {
|
|
self.update_content_sizing();
|
|
}
|
|
if update.pending_edits_flushed {
|
|
ctx.emit(RenderEvent::PendingEditsFlushed);
|
|
}
|
|
|
|
if let Some(viewport_size) = update.viewport_size {
|
|
self.set_viewport_size(viewport_size, ctx);
|
|
if self.element_tx.is_empty() {
|
|
// Don't emit this event when the channel has more current updates
|
|
// to process. This is to avoid emitting events when the viewport info is stale.
|
|
ctx.emit(RenderEvent::ViewportUpdated(update.buffer_version));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Submit a layout action.
|
|
fn submit_layout_action(&mut self, action: LayoutAction) {
|
|
#[cfg(any(test, feature = "test-util"))]
|
|
self.outstanding_layouts
|
|
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
|
|
let result = self.layout_tx.try_send(action);
|
|
debug_assert!(result.is_ok(), "Could not submit layout action: {result:?}");
|
|
}
|
|
|
|
/// Push a pending edit to the queue.
|
|
pub fn add_pending_edit(&mut self, pending_edit: EditDelta, buffer_version: BufferVersion) {
|
|
self.submit_layout_action(LayoutAction::BufferEdit {
|
|
delta: pending_edit,
|
|
buffer_version,
|
|
});
|
|
}
|
|
|
|
pub fn add_temporary_blocks(&mut self, temporary_blocks: Vec<TemporaryBlock>) {
|
|
self.submit_layout_action(LayoutAction::LayoutTemporaryBlock(temporary_blocks));
|
|
}
|
|
|
|
/// Replace all temporary blocks in the BlockItem cache with a new set of temporary
|
|
fn reset_temporary_block(&self, mut blocks: HashMap<LineCount, Vec<BlockItem>>) {
|
|
let mut new_tree = SumTree::new();
|
|
{
|
|
let content = self.content.borrow();
|
|
let mut cursor = content.cursor::<LineCount, CharOffset>();
|
|
|
|
if let Some(items) = blocks.remove(&LineCount::zero()) {
|
|
for item in items {
|
|
new_tree.push(item);
|
|
}
|
|
}
|
|
|
|
cursor.descend_to_first_item(&content, |_| true);
|
|
while let Some(item) = cursor.item() {
|
|
if !matches!(item, BlockItem::TemporaryBlock { .. }) {
|
|
new_tree.push(item.clone());
|
|
}
|
|
|
|
if let Some(items) = blocks.remove(&cursor.end_seek_position()) {
|
|
for item in items {
|
|
new_tree.push(item);
|
|
}
|
|
}
|
|
|
|
cursor.next();
|
|
}
|
|
}
|
|
self.has_final_trailing_newline
|
|
.set(Self::tree_ends_with_trailing_newline(&new_tree));
|
|
*self.content.borrow_mut() = new_tree;
|
|
}
|
|
|
|
/// Update the render state with laid out new edits.
|
|
fn layout_pending_edit(
|
|
&self,
|
|
pending_edit: LaidOutRenderDelta,
|
|
hidden_ranges: Option<RangeSet<CharOffset>>,
|
|
) {
|
|
log::trace!(
|
|
"Applying {}-line pending edit to {}..{}",
|
|
pending_edit.laid_out_line.len(),
|
|
pending_edit.old_offset.start,
|
|
pending_edit.old_offset.end
|
|
);
|
|
|
|
log::trace!(
|
|
"Incoming block replacements {:?}",
|
|
&pending_edit.laid_out_line
|
|
);
|
|
|
|
let hidden_range_clone = hidden_ranges.clone();
|
|
let mut new_tree = SumTree::new();
|
|
{
|
|
let content = self.content.borrow();
|
|
|
|
log::trace!("Initial blocks:\n{}", content.describe());
|
|
let mut cursor = content.cursor::<CharOffset, CharOffset>();
|
|
|
|
// TODO(CLD-558): Ideally, we'd use the content-level offset as is.
|
|
let effective_start = pending_edit
|
|
.old_offset
|
|
.start
|
|
.saturating_sub(&CharOffset::from(1));
|
|
|
|
// Push blocks that are not affected by the change. We don't need to recompute them.
|
|
new_tree.push_tree(cursor.slice(&effective_start, SeekBias::Right));
|
|
log::trace!(
|
|
"Keeping blocks up to {} ({}):\n{}",
|
|
pending_edit.old_offset.start,
|
|
effective_start,
|
|
new_tree.describe()
|
|
);
|
|
|
|
for item in pending_edit.laid_out_line {
|
|
let offset = new_tree.extent::<CharOffset>() + 1;
|
|
// If the item should be hidden (but it's not labelled as hidden), don't push it to the sumtree.
|
|
if !matches!(item, BlockItem::Hidden(_))
|
|
&& hidden_range_clone
|
|
.as_ref()
|
|
.map(|hr| hr.contains(&offset))
|
|
.unwrap_or(false)
|
|
{
|
|
continue;
|
|
}
|
|
new_tree.push(item);
|
|
}
|
|
|
|
// TODO(CLD-558): Ideally, we'd use the content-level offset as is.
|
|
let effective_end = pending_edit
|
|
.old_offset
|
|
.end
|
|
.saturating_sub(&CharOffset::from(1));
|
|
|
|
let sub_tree_start = *cursor.start();
|
|
let sub_tree = cursor.slice(&effective_end, SeekBias::Left);
|
|
let mut sub_tree_cursor = sub_tree.cursor::<CharOffset, CharOffset>();
|
|
sub_tree_cursor.descend_to_first_item(&sub_tree, |_| true);
|
|
|
|
while let Some(item) = sub_tree_cursor.item() {
|
|
// Do not remove the temporary blocks within the replaced range.
|
|
if matches!(item, BlockItem::TemporaryBlock { .. }) {
|
|
new_tree.push(item.clone());
|
|
}
|
|
|
|
// If there is a hidden range that overlaps the current replacement range, push it to the sumtree for now.
|
|
// This will be handled in the deduping logic below.
|
|
let offset_end = sub_tree_start + sub_tree_cursor.end();
|
|
let offset_start = sub_tree_start + *sub_tree_cursor.start();
|
|
if (offset_end > effective_end || offset_start < effective_start)
|
|
&& matches!(item, BlockItem::Hidden(_))
|
|
{
|
|
new_tree.push(item.clone());
|
|
}
|
|
sub_tree_cursor.next()
|
|
}
|
|
|
|
// We are replacing the last element of the buffer. We should add a trailing
|
|
// newline if there is one from the pending edit. Note we are replacing the last element
|
|
// iff: 1) pending edit's replacement end is not zero (we are moving cursor past the next item) 2)
|
|
// the end of cursor is past max offset of the sumtree.
|
|
if effective_end != CharOffset::zero() && cursor.end() >= self.max_offset() {
|
|
log::debug!("Adding trailing newline");
|
|
if let Some(cursor) = pending_edit.trailing_newline
|
|
&& self.should_show_final_trailing_newline(new_tree.is_empty())
|
|
{
|
|
new_tree.push(BlockItem::TrailingNewLine(cursor));
|
|
}
|
|
} else {
|
|
// Generally, we seek left and skip past the last block in the invalidated range.
|
|
// However, if we're inserting before the first block in the buffer, we want to
|
|
// keep it - since CharOffsets are non-negative and the end offset of the range is
|
|
// exclusive, we can't otherwise represent "before the first block".
|
|
if effective_end > CharOffset::zero() {
|
|
if let Some(item) = cursor.item() {
|
|
// Do not remove the temporary blocks within the replaced range.
|
|
if matches!(item, BlockItem::TemporaryBlock { .. })
|
|
|| (cursor.end() > effective_end
|
|
&& matches!(item, BlockItem::Hidden(_)))
|
|
{
|
|
new_tree.push(item.clone());
|
|
}
|
|
}
|
|
cursor.next();
|
|
} else if !new_tree.is_empty()
|
|
&& cursor.item().is_some_and(|item| item.is_trailing_newline())
|
|
&& (pending_edit.trailing_newline.is_none()
|
|
|| !self.show_final_trailing_newline_when_non_empty)
|
|
{
|
|
// Remove the trailing newline when the tree is non-empty and the
|
|
// render configuration suppresses it, or the pending edit omits one.
|
|
cursor.next();
|
|
}
|
|
|
|
let suffix = cursor.suffix();
|
|
log::trace!(
|
|
"Keeping blocks after {} ({}):\n{}",
|
|
pending_edit.old_offset.end,
|
|
effective_end,
|
|
suffix.describe()
|
|
);
|
|
new_tree.push_tree(suffix);
|
|
}
|
|
}
|
|
|
|
// Hidden range updates could be incremental. This means we might end with duplicate adjacent hidden blocks.
|
|
// This step ensures these are merged into one.
|
|
if let Some(hidden_ranges) = hidden_ranges {
|
|
new_tree = Self::dedupe_hidden_ranges(new_tree, hidden_ranges);
|
|
}
|
|
log::trace!("Resulting blocks:\n{}", new_tree.describe());
|
|
self.has_final_trailing_newline
|
|
.set(Self::tree_ends_with_trailing_newline(&new_tree));
|
|
let mut content_mut = self.content.borrow_mut();
|
|
*content_mut = new_tree;
|
|
}
|
|
|
|
/// Dedupe adjacent hidden ranges into one.
|
|
fn dedupe_hidden_ranges(
|
|
tree: SumTree<BlockItem>,
|
|
hidden_ranges: RangeSet<CharOffset>,
|
|
) -> SumTree<BlockItem> {
|
|
log::trace!("Initial tree:\n{}", tree.describe());
|
|
let mut new_tree = SumTree::new();
|
|
let mut cursor = tree.cursor::<CharOffset, CharOffset>();
|
|
|
|
let max_char_offset = tree.extent::<CharOffset>() + 1;
|
|
|
|
// Note that it's deliberate we minus one from start and keep the end as is.
|
|
// This expands our search to capture hidden ranges that are inserted prior / after the canonical range.
|
|
let ranges = hidden_ranges
|
|
.into_iter()
|
|
.sorted_by(|a, b| Ord::cmp(&a.start, &b.start))
|
|
.map(|range| range.start.saturating_sub(&CharOffset::from(1))..range.end)
|
|
.collect_vec();
|
|
|
|
for range in ranges {
|
|
let hidden_range_size = range.end - range.start - 1;
|
|
log::trace!("==== Processing range: {:?} ====", &range);
|
|
new_tree.push_tree(cursor.slice(&range.start, SeekBias::Left));
|
|
log::trace!("After pushing prefix tree:\n {}", new_tree.describe());
|
|
let sub_tree = cursor.slice(&range.end, SeekBias::Right);
|
|
|
|
log::trace!("Processing sub_tree:\n {}", sub_tree.describe());
|
|
let mut hidden_config: Option<HiddenBlockConfig> = None;
|
|
let mut staging = Vec::new();
|
|
|
|
// We want to always preserve 1) the non-hidden items 2) in the same order as they are inserted.
|
|
for item in sub_tree.cursor::<CharOffset, CharOffset>() {
|
|
if let BlockItem::Hidden(config) = item {
|
|
if let Some(prev) = &mut hidden_config {
|
|
*prev += *config;
|
|
} else {
|
|
hidden_config = Some(*config)
|
|
}
|
|
} else if hidden_config.is_none() {
|
|
new_tree.push(item.clone());
|
|
} else {
|
|
staging.push(item.clone())
|
|
}
|
|
}
|
|
|
|
if let Some(mut config) = hidden_config {
|
|
// Always resize the hidden range to its expected size.
|
|
config.content_length = hidden_range_size;
|
|
config.block_location = if range.start <= CharOffset::from(1) {
|
|
BlockLocation::Start
|
|
} else if range.end == max_char_offset {
|
|
BlockLocation::End
|
|
} else {
|
|
BlockLocation::Middle
|
|
};
|
|
new_tree.push(BlockItem::Hidden(config));
|
|
}
|
|
|
|
new_tree.extend(staging);
|
|
log::trace!("==== Finished processing range ====");
|
|
}
|
|
|
|
let suffix = cursor.suffix();
|
|
new_tree.push_tree(suffix);
|
|
|
|
log::trace!("Resulting tree:\n{}", new_tree.describe());
|
|
new_tree
|
|
}
|
|
|
|
fn update_content_sizing(&mut self) {
|
|
self.viewport.update_content_height(self.height());
|
|
self.viewport.update_content_width(self.width());
|
|
}
|
|
|
|
/// Perform an autoscroll action based on the mode.
|
|
pub fn autoscroll(&mut self, mode: AutoScrollMode, ctx: &mut ModelContext<Self>) {
|
|
let table_scroll_changed = self.reveal_autoscroll_offsets_in_tables(&mode);
|
|
let ((in_line_selection_start, in_line_selection_end), vertical_autoscroll_only) =
|
|
match mode {
|
|
AutoScrollMode::ScrollOffsetsIntoViewport(offsets) => {
|
|
let (override_start, _) = self.character_width_height_range(offsets.start);
|
|
let (_, override_end) = self.character_width_height_range(offsets.end);
|
|
((override_start, override_end), false)
|
|
}
|
|
AutoScrollMode::ScrollToExactVertical {
|
|
character_offset,
|
|
pixel_delta,
|
|
} => {
|
|
let (start, _) = self.character_width_height_range(character_offset);
|
|
if self
|
|
.viewport
|
|
.scroll_to(start.y().into_pixels() + pixel_delta, self.height())
|
|
|| table_scroll_changed
|
|
{
|
|
ctx.notify();
|
|
}
|
|
return;
|
|
}
|
|
AutoScrollMode::ScrollToActiveSelections { vertical_only } => {
|
|
let cursor_positions = self.selections().selection_map(|selection| {
|
|
self.character_width_height_range(selection.head)
|
|
});
|
|
(
|
|
Self::multiselect_autoscroll_bounding_box(
|
|
cursor_positions,
|
|
self.viewport.height(),
|
|
self.viewport.scroll_top(),
|
|
),
|
|
vertical_only,
|
|
)
|
|
}
|
|
AutoScrollMode::PositionOffsetInViewportCenter(offset) => {
|
|
let (char_start, char_end) = self.character_width_height_range(offset);
|
|
|
|
// Calculate half the viewport dimensions
|
|
let half_viewport_width = self.viewport.width().as_f32() / 2.0;
|
|
let half_viewport_height = self.viewport.height().as_f32() / 2.0;
|
|
|
|
// Compute the bounding box centered on the character position
|
|
// Start = character position - half viewport
|
|
// End = character position + half viewport
|
|
let in_line_start = vec2f(
|
|
(char_start.x() - half_viewport_width).max(0.0),
|
|
(char_start.y() - half_viewport_height).max(0.0),
|
|
);
|
|
let in_line_end = vec2f(
|
|
(char_end.x() + half_viewport_width).min(self.width().as_f32()),
|
|
(char_end.y() + half_viewport_height).min(self.height().as_f32()),
|
|
);
|
|
|
|
((in_line_start, in_line_end), false)
|
|
}
|
|
};
|
|
|
|
// We should not autoscroll if the width is fitting viewport.
|
|
let should_autoscroll_horizontally =
|
|
!matches!(self.width_setting, WidthSetting::FitViewport) && !vertical_autoscroll_only;
|
|
if self.viewport.autoscroll(
|
|
in_line_selection_start,
|
|
in_line_selection_end,
|
|
self.height(),
|
|
self.width(),
|
|
should_autoscroll_horizontally,
|
|
) || table_scroll_changed
|
|
{
|
|
ctx.notify();
|
|
}
|
|
}
|
|
|
|
fn reveal_autoscroll_offsets_in_tables(&self, mode: &AutoScrollMode) -> bool {
|
|
match mode {
|
|
AutoScrollMode::ScrollOffsetsIntoViewport(offsets) => {
|
|
let mut changed = self.reveal_offset_in_table(offsets.start);
|
|
if offsets.end > offsets.start {
|
|
changed |= self
|
|
.reveal_offset_in_table(offsets.end.saturating_sub(&CharOffset::from(1)));
|
|
}
|
|
changed
|
|
}
|
|
AutoScrollMode::ScrollToExactVertical {
|
|
character_offset, ..
|
|
}
|
|
| AutoScrollMode::PositionOffsetInViewportCenter(character_offset) => {
|
|
self.reveal_offset_in_table(*character_offset)
|
|
}
|
|
AutoScrollMode::ScrollToActiveSelections { .. } => {
|
|
self.selections().iter().fold(false, |changed, selection| {
|
|
self.reveal_offset_in_table(selection.head) || changed
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
fn reveal_offset_in_table(&self, offset: CharOffset) -> bool {
|
|
let content = self.content.borrow();
|
|
let mut cursor = content.cursor::<CharOffset, LayoutSummary>();
|
|
cursor.seek(&offset, SeekBias::Right);
|
|
|
|
let Some(block) = cursor.positioned_item() else {
|
|
return false;
|
|
};
|
|
let BlockItem::Table(laid_out_table) = block.item else {
|
|
return false;
|
|
};
|
|
if offset < block.start_char_offset || offset >= block.end_char_offset() {
|
|
return false;
|
|
}
|
|
|
|
let viewport_width =
|
|
(self.viewport.width() - block.item.spacing().x_axis_offset()).max(Pixels::zero());
|
|
laid_out_table.reveal_offset(offset - block.start_char_offset, viewport_width)
|
|
}
|
|
|
|
/// Given the coordinates of all selections, determine what is the bounding box that we want to autoscroll to.
|
|
///
|
|
/// Cases:
|
|
/// - There are selections on the screen: Autoscroll to the bounding box of the selections currently on screen.
|
|
/// - Note: Do not try to get all selections on screen. This is jarring to the user.
|
|
/// - All selections are scrolled off the bottom of the screen.
|
|
/// - Find all selections from top to bottom that would fit into one viewport, and autoscroll to that.
|
|
/// - All selections are scrolled off the top of the screen.
|
|
/// - Find all selections from bottom to top that would fit into one viewport, and autoscroll to that.
|
|
/// - There are selections above and below the viewport, but none on it.
|
|
/// - Find all selections below the viewport from top to bottom that would fit into one viewport, and autoscroll to that.
|
|
/// - Choosing to scroll down is arbitrary.
|
|
fn multiselect_autoscroll_bounding_box(
|
|
heads: Vec1<(Vector2F, Vector2F)>,
|
|
view_height: Pixels,
|
|
scroll_top: Pixels,
|
|
) -> (Vector2F, Vector2F) {
|
|
// Find selections above, below, and in the viewport.
|
|
let mut above = Vec::new();
|
|
let mut inside = Vec::new();
|
|
let mut below = Vec::new();
|
|
|
|
for head in heads {
|
|
if head.0.y().into_pixels() < scroll_top {
|
|
above.push(head);
|
|
} else if head.1.y().into_pixels() > scroll_top + view_height {
|
|
below.push(head);
|
|
} else {
|
|
inside.push(head);
|
|
};
|
|
}
|
|
|
|
let heads = if !inside.is_empty() {
|
|
// If there are any cursors showing on the screen, set those cursors as the bounding box.
|
|
// Which shouldn't scroll the screen vertically.
|
|
inside.sort_by(|(first, _), (second, _)| {
|
|
first
|
|
.y()
|
|
.partial_cmp(&second.y())
|
|
.expect("Cursor positions should be well behaved floats.")
|
|
});
|
|
inside
|
|
} else if !below.is_empty() {
|
|
// Either there are only selections below the viewport, or there are selections above and below the viewport.
|
|
// Sort from top to bottom.
|
|
below.sort_by(|(first, _), (second, _)| {
|
|
first
|
|
.y()
|
|
.partial_cmp(&second.y())
|
|
.expect("Cursor positions should be well behaved floats.")
|
|
});
|
|
below
|
|
} else if !above.is_empty() {
|
|
// There are only selections above the viewport.
|
|
// Note that above must not be empty
|
|
// Sort from bottom to top.
|
|
above.sort_by(|(first, _), (second, _)| {
|
|
first
|
|
.y()
|
|
.partial_cmp(&second.y())
|
|
.expect("Cursor positions should be well behaved floats.")
|
|
});
|
|
above.reverse();
|
|
above
|
|
} else {
|
|
// We started with a Vec1, so we should never get here.
|
|
panic!("There should be at least one selection.");
|
|
};
|
|
|
|
let (first, rest) = heads.split_first().expect("Vec1 will have at least one.");
|
|
let mut min = first.0;
|
|
let mut max = first.1;
|
|
|
|
if min.y() > max.y() || (min.y() == max.y() && min.x() > max.x()) {
|
|
mem::swap(&mut min, &mut max)
|
|
}
|
|
|
|
for (start_head, end_head) in rest {
|
|
// This is all we can fit into the viewport.
|
|
if end_head.y() - min.y() > view_height.as_f32()
|
|
|| max.y() - start_head.y() > view_height.as_f32()
|
|
{
|
|
break;
|
|
}
|
|
// If we have found a new min or max, set it.
|
|
if min.y() > start_head.y() {
|
|
min.set_y(start_head.y());
|
|
}
|
|
if max.x() < end_head.x() {
|
|
max.set_x(end_head.x());
|
|
}
|
|
if max.y() < end_head.y() {
|
|
max.set_y(end_head.y());
|
|
}
|
|
if min.x() > start_head.x() {
|
|
min.set_x(start_head.x());
|
|
}
|
|
}
|
|
(min, max)
|
|
}
|
|
|
|
fn character_width_height_range(&self, offset: CharOffset) -> (Vector2F, Vector2F) {
|
|
let content = self.content.borrow();
|
|
match self.character_bounds(offset) {
|
|
Some(bound) => (bound.origin(), bound.lower_right()),
|
|
None => {
|
|
let mut height_cursor = content.cursor::<CharOffset, LayoutSummary>();
|
|
height_cursor.seek(&offset, SeekBias::Right);
|
|
|
|
// If we are at the very end of the content tree, treat the last item's bound as (0, 0) on the last line.
|
|
(
|
|
vec2f(0., height_cursor.start().height as f32),
|
|
vec2f(0., height_cursor.end().height as f32),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn offset_to_softwrap_point(&self, offset: CharOffset) -> SoftWrapPoint {
|
|
// CharCell path: compute visual row/col from char-cell display-width arithmetic.
|
|
if let LayoutMode::CharCell(ref cc) = self.layout_mode {
|
|
return char_cell_offset_to_softwrap_point(
|
|
offset,
|
|
&cc.line_starts.borrow(),
|
|
&cc.char_widths.borrow(),
|
|
cc.terminal_width.get(),
|
|
);
|
|
}
|
|
|
|
// Pixels path: use the font-laid-out SumTree<BlockItem>.
|
|
let content = self.content.borrow();
|
|
let mut cursor = content.cursor::<CharOffset, LayoutSummary>();
|
|
|
|
cursor.seek(&offset, SeekBias::Right);
|
|
match cursor.positioned_item() {
|
|
Some(item) => item.offset_to_softwrap_point(offset),
|
|
None => SoftWrapPoint::new(self.max_line().as_u32(), ColumnUnit::pixels_zero()),
|
|
}
|
|
}
|
|
|
|
pub fn softwrap_point_to_offset(&self, point: SoftWrapPoint) -> CharOffset {
|
|
// CharCell path.
|
|
if let LayoutMode::CharCell(ref cc) = self.layout_mode {
|
|
return char_cell_softwrap_point_to_offset(
|
|
point,
|
|
&cc.line_starts.borrow(),
|
|
&cc.char_widths.borrow(),
|
|
cc.terminal_width.get(),
|
|
);
|
|
}
|
|
|
|
// Pixels path.
|
|
let content = self.content.borrow();
|
|
let mut cursor = content.cursor::<LineCount, LayoutSummary>();
|
|
|
|
let line = LineCount(point.row() as usize);
|
|
cursor.seek(&line, SeekBias::Right);
|
|
match cursor.positioned_item() {
|
|
Some(item) => item.softwrap_point_to_offset(point),
|
|
None => self.max_offset(),
|
|
}
|
|
}
|
|
|
|
/// Converts a line number to the character offset range (start, end) for that line.
|
|
/// Line numbers are 1-indexed (LineCount).
|
|
/// Returns the start offset of the line and the end offset (exclusive).
|
|
pub fn line_number_to_offset_range(&self, line_number: LineCount) -> (CharOffset, CharOffset) {
|
|
// Convert LineCount (1-indexed) to SoftWrapPoint row (0-indexed)
|
|
let line_row = line_number.as_u32().saturating_sub(1);
|
|
|
|
let start_offset =
|
|
self.softwrap_point_to_offset(SoftWrapPoint::new(line_row, ColumnUnit::pixels_zero()));
|
|
let end_offset = self
|
|
.softwrap_point_to_offset(SoftWrapPoint::new(line_row + 1, ColumnUnit::pixels_zero()));
|
|
|
|
(start_offset, end_offset)
|
|
}
|
|
|
|
/// The bounding box of the character at `offset`.
|
|
fn character_bounds(&self, offset: CharOffset) -> Option<RectF> {
|
|
let content = self.content.borrow();
|
|
let mut cursor = content.cursor::<CharOffset, LayoutSummary>();
|
|
cursor.seek(&offset, SeekBias::Right);
|
|
cursor
|
|
.positioned_item()
|
|
.and_then(|item| item.character_bounds(offset))
|
|
}
|
|
|
|
pub fn character_vertical_bounds(&self, offset: CharOffset) -> Option<(Pixels, Pixels)> {
|
|
self.character_bounds(offset).map(|bounds| {
|
|
(
|
|
Pixels::new(bounds.origin_y()),
|
|
Pixels::new(bounds.origin_y() + bounds.height()),
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Returns the bounding box of the character at `offset` in viewport-relative coordinates.
|
|
/// This can be used to position UI elements relative to text without waiting for the paint phase.
|
|
/// Returns None if the offset is out of bounds or not laid out yet.
|
|
pub fn character_bounds_in_viewport(&self, offset: CharOffset) -> Option<RectF> {
|
|
let bounds = self.character_bounds(offset)?;
|
|
|
|
// Convert from content coordinates to viewport coordinates
|
|
let scroll_top = self.viewport.scroll_top().as_f32();
|
|
let scroll_left = self.viewport.scroll_left().as_f32();
|
|
|
|
let viewport_origin = vec2f(
|
|
bounds.origin_x() - scroll_left,
|
|
bounds.origin_y() - scroll_top,
|
|
);
|
|
|
|
Some(RectF::new(viewport_origin, bounds.size()))
|
|
}
|
|
|
|
/// Saves the text selection bounding box into the position cache.
|
|
pub(super) fn record_text_selection(&self, ctx: &mut RenderContext) {
|
|
// Todo (kc CLD-1018): Save all positions, and not just one.
|
|
let selection = self.selections().first().clone();
|
|
|
|
let Some(start) = self.character_bounds(selection.start()) else {
|
|
return;
|
|
};
|
|
let Some(end) = self.character_bounds(selection.end()) else {
|
|
return;
|
|
};
|
|
let origin = start.origin().min(end.origin());
|
|
let lower_right = start.lower_right().max(end.lower_right());
|
|
|
|
// Bound the origin of the text selection cached position by the viewport (CLD-1220).
|
|
let mut screen_origin = ctx.content_to_screen(origin);
|
|
let mut screen_lower_right = ctx.content_to_screen(lower_right);
|
|
|
|
screen_origin.set_y(screen_origin.y().max(ctx.visible_bound().origin_y()));
|
|
screen_lower_right.set_y(screen_lower_right.y().max(ctx.visible_bound().origin_y()));
|
|
|
|
let bounding_box = RectF::from_points(screen_origin, screen_lower_right);
|
|
|
|
if ctx.is_visible(bounding_box) {
|
|
ctx.paint.position_cache.cache_position_for_one_frame(
|
|
self.saved_positions.text_selection_id(),
|
|
bounding_box,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Initializes the ordered list numbering state at the start of the viewport.
|
|
pub(super) fn viewport_list_numbering(&self) -> ListNumbering {
|
|
let content = self.content.borrow();
|
|
let mut cursor = content.cursor::<Height, ()>();
|
|
cursor.seek_clamped(&self.viewport.scroll_top().into(), SeekBias::Left);
|
|
|
|
// If the viewport starts with an ordered list item, we need to know its initial numbering.
|
|
// This only depends on the ordered list items immediately above the viewport. To find them,
|
|
// we need a linear scan, but it's bounded by the size of the ordered list above the
|
|
// viewport, which will generally be small. If the top of the viewport isn't an ordered
|
|
// list, we can skip this altogether.
|
|
if !matches!(cursor.item(), Some(BlockItem::OrderedList { .. })) {
|
|
return ListNumbering::new();
|
|
}
|
|
|
|
// ListNumbering can only advance forwards, so we first seek back to the start of the list
|
|
// at the viewport location.
|
|
let mut list_length = 0;
|
|
while let Some(BlockItem::OrderedList { .. }) = cursor.prev_item() {
|
|
cursor.prev();
|
|
list_length += 1;
|
|
}
|
|
|
|
let mut numbering = ListNumbering::new();
|
|
|
|
for _ in 0..list_length {
|
|
match cursor.item() {
|
|
Some(BlockItem::OrderedList {
|
|
indent_level,
|
|
number,
|
|
..
|
|
}) => {
|
|
numbering.advance(indent_level.as_usize(), *number);
|
|
}
|
|
other => {
|
|
if cfg!(debug_assertions) {
|
|
panic!("Should have an OrderedList item, got {other:?}");
|
|
}
|
|
// In production, silently skip over unexpected items.
|
|
}
|
|
}
|
|
cursor.next();
|
|
}
|
|
|
|
numbering
|
|
}
|
|
|
|
/// Log the current render state for debugging.
|
|
pub fn log_state(&self) {
|
|
log::info!("RENDER STATE:\n{}", self.describe());
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn set_content(&mut self, mut content: SumTree<BlockItem>) {
|
|
if self.should_show_final_trailing_newline(content.is_empty()) {
|
|
content.push(Self::final_trailing_newline_cursor(&self.styles));
|
|
}
|
|
self.has_final_trailing_newline
|
|
.set(Self::tree_ends_with_trailing_newline(&content));
|
|
self.content = content.into();
|
|
}
|
|
|
|
/// Scroll to the start of a given block, possibly adjusted.
|
|
#[cfg(test)]
|
|
fn scroll_near_block(&mut self, offset: CharOffset, adjustment: impl IntoPixels) {
|
|
let content = self.content.borrow();
|
|
let mut cursor = content.cursor::<CharOffset, Height>();
|
|
cursor.seek(&offset, SeekBias::Right);
|
|
self.viewport
|
|
.set_scroll_top(cursor.start().into_pixels() + adjustment.into_pixels());
|
|
}
|
|
|
|
/// Line number of the first line in the block.
|
|
pub fn start_line_index(&self, block: &dyn RenderableBlock) -> Option<LineCount> {
|
|
let content = self.content();
|
|
let offset = block.viewport_item().block_offset();
|
|
Some(content.block_at_offset(offset)?.start_line)
|
|
}
|
|
|
|
/// The line height of the first line. Different from `first_line_bounds`, this does not
|
|
/// return the viewport origin.
|
|
pub fn first_line_height(&self, block: &dyn RenderableBlock) -> Option<f32> {
|
|
let content = self.content();
|
|
let block = content.block_at_height(block.viewport_item().height())?;
|
|
Some(block.item.first_line_height())
|
|
}
|
|
|
|
/// The bounding box of the first line of this block, based on its viewport location.
|
|
pub fn first_line_bounds(
|
|
&self,
|
|
block: &dyn RenderableBlock,
|
|
ctx: &RenderContext,
|
|
) -> Option<RectF> {
|
|
let content = self.content();
|
|
let offset = block.viewport_item().block_offset();
|
|
let block = content.block_at_offset(offset)?;
|
|
Some(ctx.content_rect_to_screen(block.first_line_bounds()?))
|
|
}
|
|
|
|
pub fn line_range(&self, block: &dyn RenderableBlock) -> Option<Range<LineCount>> {
|
|
let start = self.start_line_index(block)?;
|
|
let content = self.content();
|
|
let offset = block.viewport_item().block_offset();
|
|
Some(start..start + content.block_at_offset(offset)?.item.lines())
|
|
}
|
|
}
|
|
|
|
impl Entity for RenderState {
|
|
type Event = RenderEvent;
|
|
}
|
|
|
|
/// A bundle of information computed by the element during layout, which is used to update the
|
|
/// render model.
|
|
///
|
|
/// This is how [`RenderState`] knows the viewport size.
|
|
pub(crate) struct ElementUpdate {
|
|
pub viewport_size: Option<SizeInfo>,
|
|
pub buffer_version: Option<BufferVersion>,
|
|
pub pending_edits_flushed: bool,
|
|
}
|
|
|
|
/// The mode of a requested autoscroll action.
|
|
#[derive(Debug, Clone)]
|
|
pub enum AutoScrollMode {
|
|
/// Scroll the range of offset into the viewport.
|
|
ScrollOffsetsIntoViewport(Range<CharOffset>),
|
|
/// Set scroll top to the exact vertical position of a character offset
|
|
/// with an optional pixel delta.
|
|
ScrollToExactVertical {
|
|
character_offset: CharOffset,
|
|
pixel_delta: Pixels,
|
|
},
|
|
/// Scroll to the active selections / cursors.
|
|
ScrollToActiveSelections { vertical_only: bool },
|
|
/// Scroll to position the given character offset at the center of the viewport.
|
|
/// The bounding box is computed as the character position ± half the viewport dimensions,
|
|
/// clamped to the content bounds.
|
|
PositionOffsetInViewportCenter(CharOffset),
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum PendingLayout {
|
|
Edit {
|
|
delta: EditDelta,
|
|
hidden_ranges: Option<RangeSet<CharOffset>>,
|
|
},
|
|
TemporaryBlocks(Vec<TemporaryBlock>),
|
|
}
|
|
|
|
struct PendingSelectionUpdate {
|
|
selection: RenderedSelectionSet,
|
|
buffer_version: BufferVersion,
|
|
}
|
|
|
|
/// A change to the rendering state that's processed by the background layout task.
|
|
#[derive(Debug, Clone)]
|
|
enum LayoutAction {
|
|
/// The selection state has changed. We dispatch this through the layout pipeline so that
|
|
/// the on-screen cursor position only changes _after_ any relevant text is laid out.
|
|
SelectionChanged {
|
|
selections: RenderedSelectionSet,
|
|
buffer_version: BufferVersion,
|
|
},
|
|
DecorationChanged(UpdateDecorationAfterLayout),
|
|
/// The buffer was edited.
|
|
BufferEdit {
|
|
delta: EditDelta,
|
|
buffer_version: BufferVersion,
|
|
},
|
|
LayoutTemporaryBlock(Vec<TemporaryBlock>),
|
|
/// Autoscroll, to the specified range if `Some` or to the cursor location if `None`.
|
|
Autoscroll {
|
|
mode: AutoScrollMode,
|
|
},
|
|
/// Scroll to a snapshotted scroll position.
|
|
ScrollTo(ScrollPositionSnapshot),
|
|
}
|
|
|
|
impl From<Pixels> for Height {
|
|
fn from(value: Pixels) -> Self {
|
|
Self(OrderedFloat(value.as_f32().into()))
|
|
}
|
|
}
|
|
|
|
impl From<f32> for Height {
|
|
fn from(value: f32) -> Self {
|
|
value.into_pixels().into()
|
|
}
|
|
}
|
|
|
|
impl From<Height> for Pixels {
|
|
fn from(value: Height) -> Self {
|
|
(value.0.0 as f32).into_pixels()
|
|
}
|
|
}
|
|
|
|
impl IntoPixels for Height {
|
|
fn into_pixels(self) -> Pixels {
|
|
(self.0.0 as f32).into_pixels()
|
|
}
|
|
}
|
|
|
|
impl RichTextStyles {
|
|
/// The base line height for plain text. For consistency, this is the line
|
|
/// height used for scrolling - otherwise, the user would scroll faster past
|
|
/// items with a larger font size.
|
|
pub fn base_line_height(&self) -> Pixels {
|
|
self.base_text.line_height()
|
|
}
|
|
|
|
/// Selects the paragraph styles that apply to a block style.
|
|
pub fn paragraph_styles(&self, block_style: &BufferBlockStyle) -> ParagraphStyles {
|
|
match block_style {
|
|
BufferBlockStyle::PlainText
|
|
| BufferBlockStyle::UnorderedList { .. }
|
|
| BufferBlockStyle::OrderedList { .. }
|
|
| BufferBlockStyle::TaskList { .. } => self.base_text,
|
|
BufferBlockStyle::Table { .. } => {
|
|
let mut style = self.base_text;
|
|
style.font_family = self.table_style.font_family;
|
|
style.font_size = self.table_style.font_size;
|
|
style
|
|
}
|
|
BufferBlockStyle::CodeBlock { .. } => self.code_text,
|
|
BufferBlockStyle::Header { header_size } => {
|
|
let mut base_text_style = self.base_text;
|
|
base_text_style.font_size *= header_size.font_size_multiplication_ratio();
|
|
base_text_style.font_weight = Weight::from_custom_weight(header_size.font_weight());
|
|
base_text_style
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn requires_relayout(&self, new_styles: &RichTextStyles) -> bool {
|
|
if self == new_styles {
|
|
return false;
|
|
}
|
|
|
|
self.base_text.requires_relayout(&new_styles.base_text)
|
|
|| self.code_text.requires_relayout(&new_styles.code_text)
|
|
|| self
|
|
.embedding_text
|
|
.requires_relayout(&new_styles.embedding_text)
|
|
|| self
|
|
.inline_code_style
|
|
.requires_relayout(&new_styles.inline_code_style)
|
|
|| self.table_style.requires_relayout(&new_styles.table_style)
|
|
|| self.minimum_paragraph_height != new_styles.minimum_paragraph_height
|
|
}
|
|
}
|
|
|
|
impl ParagraphStyles {
|
|
pub fn line_style(&self) -> LineStyle {
|
|
LineStyle {
|
|
font_size: self.font_size,
|
|
line_height_ratio: self.line_height_ratio,
|
|
baseline_ratio: self.baseline_ratio,
|
|
fixed_width_tab_size: self.fixed_width_tab_size,
|
|
}
|
|
}
|
|
|
|
pub fn line_height(&self) -> Pixels {
|
|
(self.font_size * self.line_height_ratio).into_pixels()
|
|
}
|
|
|
|
/// Default font properties for paragraphs of this style. They may be overridden by inline
|
|
/// styles.
|
|
pub fn properties(&self) -> Properties {
|
|
Properties::default().weight(self.font_weight)
|
|
}
|
|
|
|
fn requires_relayout(&self, new_styles: &ParagraphStyles) -> bool {
|
|
self.font_size != new_styles.font_size
|
|
|| self.line_height_ratio != new_styles.line_height_ratio
|
|
|| self.baseline_ratio != new_styles.baseline_ratio
|
|
|| self.font_weight != new_styles.font_weight
|
|
|| self.font_family != new_styles.font_family
|
|
|| self.fixed_width_tab_size != new_styles.fixed_width_tab_size
|
|
}
|
|
}
|
|
|
|
impl AddAssign<&LayoutSummary> for LayoutSummary {
|
|
fn add_assign(&mut self, rhs: &LayoutSummary) {
|
|
self.height += rhs.height;
|
|
self.content_length += rhs.content_length;
|
|
self.width = self.width.max(rhs.width);
|
|
self.lines += rhs.lines;
|
|
self.item_count += rhs.item_count;
|
|
}
|
|
}
|
|
|
|
impl BlockItem {
|
|
pub fn paragraph(
|
|
frame: Arc<TextFrame>,
|
|
offsets: OffsetMap,
|
|
content_length: CharOffset,
|
|
spacing: BlockSpacing,
|
|
minimum_height: Option<Pixels>,
|
|
) -> BlockItem {
|
|
BlockItem::Paragraph(Paragraph::new(
|
|
frame,
|
|
offsets,
|
|
content_length,
|
|
vec![],
|
|
spacing,
|
|
minimum_height,
|
|
))
|
|
}
|
|
|
|
pub fn first_line_height(&self) -> f32 {
|
|
match self {
|
|
BlockItem::Paragraph(paragraph)
|
|
| BlockItem::Header { paragraph, .. }
|
|
| BlockItem::TaskList { paragraph, .. }
|
|
| BlockItem::UnorderedList { paragraph, .. }
|
|
| BlockItem::OrderedList { paragraph, .. } => paragraph.first_line_height(),
|
|
BlockItem::TextBlock { paragraph_block } => paragraph_block.first_line_height(),
|
|
BlockItem::RunnableCodeBlock {
|
|
paragraph_block, ..
|
|
}
|
|
| BlockItem::TemporaryBlock {
|
|
paragraph_block, ..
|
|
} => paragraph_block.first_line_height(),
|
|
BlockItem::MermaidDiagram { config, .. } => config.height.as_f32(),
|
|
BlockItem::TrailingNewLine(cursor) => cursor.height.as_f32(),
|
|
BlockItem::HorizontalRule(config) => config.line_height.as_f32(),
|
|
BlockItem::Image { config, .. } => config.height.as_f32(),
|
|
BlockItem::Table(laid_out_table) => laid_out_table.height().as_f32(),
|
|
BlockItem::Embedded(embedded_item) => embedded_item.height().as_f32(),
|
|
BlockItem::Hidden(config) => config.height().as_f32(),
|
|
}
|
|
}
|
|
|
|
pub fn spacing(&self) -> BlockSpacing {
|
|
match self {
|
|
BlockItem::Paragraph(paragraph)
|
|
| BlockItem::Header { paragraph, .. }
|
|
| BlockItem::TaskList { paragraph, .. }
|
|
| BlockItem::UnorderedList { paragraph, .. }
|
|
| BlockItem::OrderedList { paragraph, .. } => paragraph.spacing(),
|
|
BlockItem::TextBlock { paragraph_block } => paragraph_block.spacing(),
|
|
BlockItem::RunnableCodeBlock {
|
|
paragraph_block, ..
|
|
}
|
|
| BlockItem::TemporaryBlock {
|
|
paragraph_block, ..
|
|
} => paragraph_block.spacing(),
|
|
BlockItem::MermaidDiagram { config, .. } => config.spacing,
|
|
BlockItem::TrailingNewLine(cursor) => cursor.spacing(),
|
|
BlockItem::HorizontalRule(config) => config.spacing,
|
|
BlockItem::Image { config, .. } => config.spacing,
|
|
BlockItem::Table(laid_out_table) => laid_out_table.spacing(),
|
|
BlockItem::Embedded(embedded_item) => embedded_item.spacing(),
|
|
BlockItem::Hidden { .. } => BlockSpacing::default(),
|
|
}
|
|
}
|
|
|
|
/// The height of this item's content, without any padding or margins.
|
|
pub fn content_height(&self) -> Pixels {
|
|
match self {
|
|
BlockItem::Paragraph(paragraph) | BlockItem::Header { paragraph, .. } => {
|
|
let mut height = paragraph.height;
|
|
if let Some(minimum_height) = paragraph.minimum_height {
|
|
height = height.max(minimum_height);
|
|
}
|
|
height
|
|
}
|
|
BlockItem::TextBlock { paragraph_block } => paragraph_block.height(),
|
|
BlockItem::UnorderedList { paragraph, .. }
|
|
| BlockItem::OrderedList { paragraph, .. }
|
|
| BlockItem::TaskList { paragraph, .. } => paragraph.height(),
|
|
BlockItem::RunnableCodeBlock {
|
|
paragraph_block, ..
|
|
}
|
|
| BlockItem::TemporaryBlock {
|
|
paragraph_block, ..
|
|
} => paragraph_block.height(),
|
|
BlockItem::MermaidDiagram { config, .. } => config.height,
|
|
BlockItem::TrailingNewLine(cursor) => {
|
|
let mut height = cursor.height;
|
|
if let Some(minimum_height) = cursor.minimum_height {
|
|
height = height.max(minimum_height);
|
|
}
|
|
height
|
|
}
|
|
BlockItem::Embedded(embedded_item) => embedded_item.height(),
|
|
BlockItem::HorizontalRule(rule) => rule.line_height,
|
|
BlockItem::Image { config, .. } => config.height,
|
|
BlockItem::Table(laid_out_table) => laid_out_table.height(),
|
|
BlockItem::Hidden(config) => config.height(),
|
|
}
|
|
}
|
|
|
|
pub fn content_width(&self) -> Pixels {
|
|
match self {
|
|
BlockItem::Paragraph(paragraph)
|
|
| BlockItem::Header { paragraph, .. }
|
|
| BlockItem::UnorderedList { paragraph, .. }
|
|
| BlockItem::OrderedList { paragraph, .. }
|
|
| BlockItem::TaskList { paragraph, .. } => paragraph.width(),
|
|
BlockItem::TextBlock { paragraph_block } => paragraph_block.width(),
|
|
BlockItem::RunnableCodeBlock {
|
|
paragraph_block, ..
|
|
}
|
|
| BlockItem::TemporaryBlock {
|
|
paragraph_block, ..
|
|
} => paragraph_block.width(),
|
|
BlockItem::MermaidDiagram { config, .. } => config.width,
|
|
BlockItem::TrailingNewLine(cursor) => cursor.width,
|
|
BlockItem::Embedded(object) => object.size().x().into_pixels(),
|
|
BlockItem::HorizontalRule(rule) => rule.width,
|
|
BlockItem::Image { config, .. } => config.width,
|
|
BlockItem::Table(laid_out_table) => laid_out_table.width(),
|
|
BlockItem::Hidden(_) => MIN_HIDDEN_BLOCK_WIDTH,
|
|
}
|
|
}
|
|
|
|
/// The total height of this item, including content, padding, and margins.
|
|
pub fn height(&self) -> Pixels {
|
|
self.content_height() + self.spacing().y_axis_offset()
|
|
}
|
|
|
|
pub fn width(&self) -> Pixels {
|
|
self.content_width() + self.spacing().x_axis_offset()
|
|
}
|
|
|
|
pub fn content_length(&self) -> CharOffset {
|
|
match self {
|
|
BlockItem::Paragraph(paragraph)
|
|
| BlockItem::Header { paragraph, .. }
|
|
| BlockItem::UnorderedList { paragraph, .. }
|
|
| BlockItem::OrderedList { paragraph, .. }
|
|
| BlockItem::TaskList { paragraph, .. } => paragraph.content_length,
|
|
BlockItem::TextBlock { paragraph_block } => paragraph_block.content_length(),
|
|
BlockItem::RunnableCodeBlock {
|
|
paragraph_block, ..
|
|
} => paragraph_block.content_length(),
|
|
BlockItem::TemporaryBlock { .. } => CharOffset::zero(),
|
|
BlockItem::MermaidDiagram { content_length, .. } => *content_length,
|
|
BlockItem::TrailingNewLine(_)
|
|
| BlockItem::Embedded(_)
|
|
| BlockItem::HorizontalRule(_)
|
|
| BlockItem::Image { .. } => CharOffset::from(1),
|
|
BlockItem::Table(laid_out_table) => laid_out_table.content_length(),
|
|
BlockItem::Hidden(config) => config.content_length(),
|
|
}
|
|
}
|
|
|
|
pub fn lines(&self) -> LineCount {
|
|
match self {
|
|
BlockItem::Paragraph(paragraph)
|
|
| BlockItem::Header { paragraph, .. }
|
|
| BlockItem::UnorderedList { paragraph, .. }
|
|
| BlockItem::OrderedList { paragraph, .. }
|
|
| BlockItem::TaskList { paragraph, .. } => paragraph.lines(),
|
|
BlockItem::TextBlock { paragraph_block } => paragraph_block.lines(),
|
|
BlockItem::RunnableCodeBlock {
|
|
paragraph_block, ..
|
|
} => paragraph_block.lines(),
|
|
BlockItem::TemporaryBlock { .. } => LineCount(0),
|
|
BlockItem::MermaidDiagram { .. } => LineCount(1),
|
|
BlockItem::TrailingNewLine(_)
|
|
| BlockItem::Embedded(_)
|
|
| BlockItem::HorizontalRule(_)
|
|
| BlockItem::Image { .. } => LineCount(1),
|
|
BlockItem::Table(laid_out_table) => laid_out_table.lines(),
|
|
BlockItem::Hidden(config) => config.line_count(),
|
|
}
|
|
}
|
|
|
|
/// Returns `true` if this item is effectively empty. A newline-only block would be considered
|
|
/// empty, despite having a content length of 1.
|
|
pub fn is_empty(&self) -> bool {
|
|
match self {
|
|
BlockItem::Paragraph(paragraph)
|
|
| BlockItem::Header { paragraph, .. }
|
|
| BlockItem::UnorderedList { paragraph, .. }
|
|
| BlockItem::OrderedList { paragraph, .. }
|
|
| BlockItem::TaskList { paragraph, .. } => paragraph.is_empty(),
|
|
BlockItem::TextBlock { paragraph_block } => paragraph_block.is_empty(),
|
|
BlockItem::RunnableCodeBlock {
|
|
paragraph_block, ..
|
|
} => paragraph_block.is_empty(),
|
|
BlockItem::MermaidDiagram { .. } => false,
|
|
// Embeds, images, tables, and horizontal rules are never empty.
|
|
BlockItem::Embedded(_)
|
|
| BlockItem::HorizontalRule(_)
|
|
| BlockItem::Image { .. }
|
|
| BlockItem::Table(_)
|
|
| BlockItem::TemporaryBlock { .. } => false,
|
|
// The trailing newline placeholder and hidden blocks are always considered empty.
|
|
BlockItem::TrailingNewLine(_) | BlockItem::Hidden { .. } => true,
|
|
}
|
|
}
|
|
|
|
pub fn is_hidden(&self) -> bool {
|
|
matches!(self, BlockItem::Hidden { .. })
|
|
}
|
|
|
|
pub fn is_trailing_newline(&self) -> bool {
|
|
matches!(self, BlockItem::TrailingNewLine(_))
|
|
}
|
|
}
|
|
|
|
impl Positioned<'_, BlockItem> {
|
|
fn softwrap_point_to_offset(&self, point: SoftWrapPoint) -> CharOffset {
|
|
match self.item {
|
|
BlockItem::Paragraph(inner) => self.paragraph(inner).softwrap_point_to_offset(point),
|
|
BlockItem::TextBlock { paragraph_block } => {
|
|
let text_block = self.text_block(paragraph_block);
|
|
|
|
let mut paragraphs = text_block.paragraphs();
|
|
paragraphs
|
|
.find(|paragraph| paragraph.end_line().as_u32() > point.row())
|
|
.map_or(self.end_char_offset(), |paragraph| {
|
|
paragraph.softwrap_point_to_offset(point)
|
|
})
|
|
}
|
|
BlockItem::UnorderedList { paragraph, .. } => self
|
|
.unordered_list(paragraph)
|
|
.softwrap_point_to_offset(point),
|
|
BlockItem::OrderedList { paragraph, .. } => {
|
|
self.ordered_list(paragraph).softwrap_point_to_offset(point)
|
|
}
|
|
BlockItem::TaskList { paragraph, .. } => {
|
|
self.task_list(paragraph).softwrap_point_to_offset(point)
|
|
}
|
|
BlockItem::Header {
|
|
paragraph: inner, ..
|
|
} => self.header(inner).softwrap_point_to_offset(point),
|
|
BlockItem::RunnableCodeBlock {
|
|
paragraph_block, ..
|
|
} => {
|
|
let code_block = self.code_block(paragraph_block);
|
|
|
|
let mut paragraphs = code_block.paragraphs();
|
|
paragraphs
|
|
.find(|paragraph| paragraph.end_line().as_u32() > point.row())
|
|
.map_or(self.end_char_offset(), |paragraph| {
|
|
paragraph.softwrap_point_to_offset(point)
|
|
})
|
|
}
|
|
BlockItem::MermaidDiagram { .. } => self.start_char_offset,
|
|
BlockItem::TrailingNewLine(_)
|
|
| BlockItem::Embedded(_)
|
|
| BlockItem::HorizontalRule(_)
|
|
| BlockItem::Image { .. }
|
|
| BlockItem::TemporaryBlock { .. }
|
|
| BlockItem::Hidden { .. } => self.start_char_offset,
|
|
BlockItem::Table(laid_out_table) => {
|
|
let row_in_table = point.row().saturating_sub(self.start_line.as_u32()) as usize;
|
|
if let Some(cell_range) = laid_out_table.offset_map.cell_range(row_in_table, 0) {
|
|
let visible_cell_start = laid_out_table
|
|
.cell_offset_maps
|
|
.get(row_in_table)
|
|
.and_then(|row| row.first())
|
|
.map(|cell| cell.rendered_to_source(CharOffset::zero()))
|
|
.unwrap_or(CharOffset::zero());
|
|
self.start_char_offset + cell_range.start + visible_cell_start.as_usize()
|
|
} else {
|
|
self.start_char_offset
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn offset_to_softwrap_point(&self, offset: CharOffset) -> SoftWrapPoint {
|
|
match self.item {
|
|
BlockItem::Paragraph(inner) => self.paragraph(inner).offset_to_softwrap_point(offset),
|
|
BlockItem::TextBlock { paragraph_block } => {
|
|
let text_block = self.text_block(paragraph_block);
|
|
|
|
let mut paragraphs = text_block.paragraphs();
|
|
paragraphs
|
|
.find(|paragraph| paragraph.end_char_offset() > offset)
|
|
.map_or(
|
|
SoftWrapPoint::new(self.end_line().as_u32(), ColumnUnit::pixels_zero()),
|
|
|paragraph| paragraph.offset_to_softwrap_point(offset),
|
|
)
|
|
}
|
|
BlockItem::UnorderedList { paragraph, .. } => self
|
|
.unordered_list(paragraph)
|
|
.offset_to_softwrap_point(offset),
|
|
BlockItem::OrderedList { paragraph, .. } => self
|
|
.ordered_list(paragraph)
|
|
.offset_to_softwrap_point(offset),
|
|
BlockItem::Header { paragraph, .. } => {
|
|
self.header(paragraph).offset_to_softwrap_point(offset)
|
|
}
|
|
BlockItem::TaskList { paragraph, .. } => {
|
|
self.task_list(paragraph).offset_to_softwrap_point(offset)
|
|
}
|
|
BlockItem::RunnableCodeBlock {
|
|
paragraph_block, ..
|
|
} => {
|
|
let code_block = self.code_block(paragraph_block);
|
|
|
|
let mut paragraphs = code_block.paragraphs();
|
|
paragraphs
|
|
.find(|paragraph| paragraph.end_char_offset() > offset)
|
|
.map_or(
|
|
SoftWrapPoint::new(self.end_line().as_u32(), ColumnUnit::pixels_zero()),
|
|
|paragraph| paragraph.offset_to_softwrap_point(offset),
|
|
)
|
|
}
|
|
BlockItem::MermaidDiagram { .. } => {
|
|
SoftWrapPoint::new(self.start_line.as_u32(), ColumnUnit::pixels_zero())
|
|
}
|
|
BlockItem::TrailingNewLine(_)
|
|
| BlockItem::Embedded(_)
|
|
| BlockItem::HorizontalRule(_)
|
|
| BlockItem::Image { .. }
|
|
| BlockItem::TemporaryBlock { .. }
|
|
| BlockItem::Hidden { .. } => {
|
|
SoftWrapPoint::new(self.start_line.as_u32(), ColumnUnit::pixels_zero())
|
|
}
|
|
BlockItem::Table(laid_out_table) => {
|
|
let relative_offset = offset.saturating_sub(&self.start_char_offset);
|
|
let row = laid_out_table
|
|
.offset_map
|
|
.cell_at_offset(relative_offset)
|
|
.map(|c| c.row)
|
|
.unwrap_or(0);
|
|
SoftWrapPoint::new(
|
|
self.start_line.as_u32() + row as u32,
|
|
ColumnUnit::pixels_zero(),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Given a [`CharOffset`], finds the bounding box of the character at that offset (to the extent possible).
|
|
/// The bounding box is relative to the buffer origin.
|
|
fn character_bounds(&self, offset: CharOffset) -> Option<RectF> {
|
|
match self.item {
|
|
BlockItem::Paragraph(inner) => self.paragraph(inner).character_bounds(offset),
|
|
BlockItem::TextBlock { paragraph_block } => {
|
|
let text_block = self.text_block(paragraph_block);
|
|
text_block
|
|
.paragraphs()
|
|
.find_or_last(|paragraph| paragraph.end_char_offset() > offset)
|
|
.and_then(|paragraph| paragraph.character_bounds(offset))
|
|
}
|
|
BlockItem::UnorderedList { paragraph, .. } => {
|
|
self.unordered_list(paragraph).character_bounds(offset)
|
|
}
|
|
BlockItem::OrderedList { paragraph, .. } => {
|
|
self.ordered_list(paragraph).character_bounds(offset)
|
|
}
|
|
BlockItem::TaskList { paragraph, .. } => {
|
|
self.task_list(paragraph).character_bounds(offset)
|
|
}
|
|
BlockItem::Header { paragraph, .. } => self.header(paragraph).character_bounds(offset),
|
|
BlockItem::RunnableCodeBlock {
|
|
paragraph_block, ..
|
|
} => {
|
|
let code_block = self.code_block(paragraph_block);
|
|
code_block
|
|
.paragraphs()
|
|
.find_or_last(|paragraph| paragraph.end_char_offset() > offset)
|
|
.and_then(|paragraph| paragraph.character_bounds(offset))
|
|
}
|
|
BlockItem::MermaidDiagram { config, .. } => {
|
|
let origin = self.content_origin();
|
|
Some(RectF::new(
|
|
origin,
|
|
vec2f(config.width.as_f32(), config.height.as_f32()),
|
|
))
|
|
}
|
|
BlockItem::TrailingNewLine(cursor) => {
|
|
let origin = self.content_origin();
|
|
Some(RectF::new(origin, cursor.size()))
|
|
}
|
|
BlockItem::HorizontalRule(rule) => {
|
|
let origin = self.content_origin();
|
|
Some(RectF::new(origin, rule.line_size()))
|
|
}
|
|
BlockItem::Image { config, .. } => {
|
|
let origin = self.content_origin();
|
|
Some(RectF::new(
|
|
origin,
|
|
vec2f(config.width.as_f32(), config.height.as_f32()),
|
|
))
|
|
}
|
|
BlockItem::Table(laid_out_table) => {
|
|
if offset < self.start_char_offset || offset >= self.end_char_offset() {
|
|
None
|
|
} else {
|
|
laid_out_table
|
|
.character_bounds(offset - self.start_char_offset, self.content_origin())
|
|
}
|
|
}
|
|
BlockItem::Embedded(embedded_item) => {
|
|
let origin = self.content_origin();
|
|
Some(RectF::new(origin, embedded_item.size()))
|
|
}
|
|
BlockItem::TemporaryBlock { .. } | BlockItem::Hidden { .. } => None,
|
|
}
|
|
}
|
|
|
|
/// The bounds of the first line of this item, relative to the buffer origin.
|
|
pub fn first_line_bounds(&self) -> Option<RectF> {
|
|
let line_bounds = match self.item {
|
|
BlockItem::Paragraph(inner) => self.paragraph(inner).first_line_bounds()?,
|
|
BlockItem::TextBlock { paragraph_block } => {
|
|
self.text_block(paragraph_block).first_line_bounds()?
|
|
}
|
|
BlockItem::UnorderedList { paragraph, .. } => {
|
|
self.unordered_list(paragraph).first_line_bounds()?
|
|
}
|
|
BlockItem::TaskList { paragraph, .. } => {
|
|
self.task_list(paragraph).first_line_bounds()?
|
|
}
|
|
BlockItem::OrderedList { paragraph, .. } => {
|
|
self.ordered_list(paragraph).first_line_bounds()?
|
|
}
|
|
BlockItem::Header { paragraph, .. } => self.header(paragraph).first_line_bounds()?,
|
|
BlockItem::RunnableCodeBlock {
|
|
paragraph_block, ..
|
|
} => {
|
|
// For code blocks, we treat the top padding as the first line.
|
|
let first_line = self.code_block(paragraph_block).first_line_bounds()?;
|
|
RectF::from_points(self.visible_origin(), first_line.upper_right())
|
|
}
|
|
BlockItem::MermaidDiagram { config, .. } => {
|
|
let origin = self.visible_origin();
|
|
RectF::new(origin, vec2f(config.width.as_f32(), config.height.as_f32()))
|
|
}
|
|
BlockItem::TrailingNewLine(cursor) => {
|
|
let origin = self.trailing_newline(cursor).content_origin();
|
|
RectF::new(origin, cursor.size())
|
|
}
|
|
BlockItem::HorizontalRule(rule) => {
|
|
let origin = self.content_origin();
|
|
RectF::new(origin, rule.line_size())
|
|
}
|
|
BlockItem::Image { config, .. } => {
|
|
let origin = self.content_origin();
|
|
RectF::new(origin, vec2f(config.width.as_f32(), config.height.as_f32()))
|
|
}
|
|
BlockItem::Table(laid_out_table) => {
|
|
let origin = self.content_origin();
|
|
RectF::new(
|
|
origin,
|
|
vec2f(
|
|
laid_out_table.width().as_f32(),
|
|
laid_out_table.height().as_f32(),
|
|
),
|
|
)
|
|
}
|
|
BlockItem::Embedded(embedded_item) => {
|
|
let origin = self.visible_origin();
|
|
RectF::new(origin, embedded_item.first_line_bound())
|
|
}
|
|
BlockItem::TemporaryBlock { .. } | BlockItem::Hidden { .. } => return None,
|
|
};
|
|
|
|
// At the block level, we want to include any space for list bullets and other
|
|
// decorations/padding in the first line.
|
|
let flush_origin = vec2f(self.reserved_origin().x(), line_bounds.origin_y());
|
|
Some(RectF::from_points(flush_origin, line_bounds.lower_right()))
|
|
}
|
|
}
|
|
|
|
impl sum_tree::Item for BlockItem {
|
|
type Summary = LayoutSummary;
|
|
|
|
fn summary(&self) -> Self::Summary {
|
|
LayoutSummary {
|
|
content_length: self.content_length(),
|
|
// We use f64 to represent heights to avoid cumulative precision error from iteratively adding
|
|
// block heights when querying / constructing the tree. In rendering, we don't actually need
|
|
// high decimal point precisions so it's fine to cast from f32 to f64 here.
|
|
height: self.height().as_f32() as f64,
|
|
width: self.width(),
|
|
lines: self.lines(),
|
|
item_count: 1,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'a> Positioned<'a, Paragraph> {
|
|
/// Iterator over the lines of a paragraph, along with positioning information.
|
|
fn lines(&self) -> impl Iterator<Item = Positioned<'a, Line>> + '_ {
|
|
self.item.frame.lines().iter().scan(
|
|
(
|
|
self.start_y_offset + self.style.top_offset(),
|
|
self.start_line,
|
|
),
|
|
|(y_offset_acc, line_acc), line| {
|
|
let positioned = Some(Positioned {
|
|
start_y_offset: *y_offset_acc,
|
|
// Lines record their character index relative to the TextFrame start,
|
|
// so we can keep its starting offset for each.
|
|
start_char_offset: self.start_char_offset,
|
|
start_line: *line_acc,
|
|
// Remove y axis offset here because the paragraph level styling should
|
|
// not apply to each text frame line.
|
|
style: self.style.without_y_axis_offsets(),
|
|
item: line,
|
|
});
|
|
*y_offset_acc += line_height(line).into_pixels();
|
|
*line_acc += LineCount(1);
|
|
positioned
|
|
},
|
|
)
|
|
}
|
|
|
|
/// The bounds of the first line of this paragraph, relative to the content origin. This is
|
|
/// useful for UI elements positioned alongside the start of the paragraph.
|
|
fn first_line_bounds(&self) -> Option<RectF> {
|
|
let first_line = self.lines().next()?;
|
|
let size = vec2f(first_line.item.width, self.item.first_line_height());
|
|
Some(RectF::new(first_line.content_origin(), size))
|
|
}
|
|
|
|
pub fn end_char_offset(&self) -> CharOffset {
|
|
self.start_char_offset + self.item.content_length
|
|
}
|
|
|
|
pub fn end_line(&self) -> LineCount {
|
|
self.start_line + self.item.lines()
|
|
}
|
|
|
|
// Maximum softwrap point in the paragraph on the last line.
|
|
pub fn max_softwrap_point(&self) -> SoftWrapPoint {
|
|
match self.lines().last() {
|
|
Some(line) => SoftWrapPoint::new(
|
|
line.start_line.as_u32(),
|
|
ColumnUnit::Pixels(line.item.width.into_pixels() + self.style.left_offset()),
|
|
),
|
|
None => SoftWrapPoint::new(self.start_line.as_u32(), ColumnUnit::pixels_zero()),
|
|
}
|
|
}
|
|
|
|
pub fn end_y_offset(&self) -> Pixels {
|
|
self.start_y_offset + self.item.height + self.style.top_offset()
|
|
}
|
|
|
|
/// The bounding box of the character at `offset`, if within this paragraph.
|
|
fn character_bounds(&self, offset: CharOffset) -> Option<RectF> {
|
|
let (line, frame_offset) = self.offset_to_frame_location(offset)?;
|
|
let x_offset = line.item.caret_position_for_index(frame_offset.as_usize());
|
|
// We don't have easy access to the width of a given glyph, so use the next caret position
|
|
// instead. This will clamp to the end of the line if needed.
|
|
let next_x_offset = line
|
|
.item
|
|
.caret_position_for_index(frame_offset.as_usize() + 1);
|
|
let origin = line.content_origin() + vec2f(x_offset, 0.);
|
|
let size = vec2f(next_x_offset - x_offset, line_height(line.item));
|
|
Some(RectF::new(origin, size))
|
|
}
|
|
|
|
/// Resolves a content `CharOffset` to the corresponding frame offset and containing line, if
|
|
/// it's in bounds for this paragraph.
|
|
fn offset_to_frame_location(
|
|
&self,
|
|
offset: CharOffset,
|
|
) -> Option<(Positioned<'_, Line>, FrameOffset)> {
|
|
if offset < self.start_char_offset || offset >= self.end_char_offset() {
|
|
return None;
|
|
}
|
|
let offset_within_frame = self.frame_index(offset);
|
|
|
|
// We've already checked that `offset` is in bounds. In that case,
|
|
// if no line contains the offset, it's probably at the newline
|
|
// ending the paragraph (which doesn't have a caret position of
|
|
// its own). If we're at the newline, then use the end of the
|
|
// paragraph as the pixel position.
|
|
let line = self.lines().find_or_last(|line| {
|
|
// TODO(ben): handle clamping above/below
|
|
line.item.last_index() >= offset_within_frame.as_usize()
|
|
})?;
|
|
|
|
Some((line, offset_within_frame))
|
|
}
|
|
|
|
/// Draws any selection highlights and cursors that are within this paragraph.
|
|
pub(super) fn draw_selection(&self, model: &RenderState, ctx: &mut RenderContext) {
|
|
let styles = &model.styles;
|
|
for (i, selection) in model.selections().iter().enumerate() {
|
|
let start = selection.start();
|
|
let end = selection.end();
|
|
let bias = selection.cursor_bias();
|
|
// Where we should paint the cursor. This is not always the same as `start`.
|
|
let cursor_offset = selection.head;
|
|
|
|
// If the selection is a cursor, don't also draw a selection highlight.
|
|
if start != end {
|
|
self.draw_highlight(start, end, styles.selection_fill, ctx, model.max_line());
|
|
} else if let Some(VimMode::Visual(_)) = ctx.vim_mode {
|
|
// If we're in Vim visual mode, render the visual mode selection.
|
|
let Some(visual_tail) = ctx.vim_visual_tails.get(i) else {
|
|
continue;
|
|
};
|
|
|
|
let visual_start = *visual_tail;
|
|
let visual_end = selection.head;
|
|
let (visual_start, visual_end) = if visual_start > visual_end {
|
|
(visual_end, visual_start)
|
|
} else {
|
|
(visual_start, visual_end)
|
|
};
|
|
|
|
self.draw_highlight(
|
|
visual_start,
|
|
visual_end,
|
|
styles.selection_fill,
|
|
ctx,
|
|
model.max_line(),
|
|
);
|
|
}
|
|
|
|
if cursor_offset >= self.start_char_offset && cursor_offset < self.end_char_offset() {
|
|
self.draw_cursor(cursor_offset, bias, styles, ctx);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn draw_cursor(
|
|
&self,
|
|
offset: CharOffset,
|
|
bias: Option<RenderedSelectionBias>,
|
|
styles: &RichTextStyles,
|
|
ctx: &mut RenderContext,
|
|
) {
|
|
let Some((line, frame_offset)) = self.offset_to_frame_location(offset) else {
|
|
// The cursor isn't within this paragraph.
|
|
return;
|
|
};
|
|
|
|
let delta = match bias {
|
|
Some(RenderedSelectionBias::Left) => -line.item.font_size / 8.,
|
|
Some(RenderedSelectionBias::Right) => line.item.font_size / 8.,
|
|
None => 0.,
|
|
};
|
|
|
|
// TODO: Instead of tracking content_origin and text_origin separately, it might be
|
|
// simpler to track a start_position (rather than start_y_offset) in Positioned. Then,
|
|
// blocks with padding (like a code block) could position their children with both
|
|
// horizontal and vertical offsets.
|
|
let cursor_position = line.content_origin()
|
|
+ vec2f(
|
|
line.item.caret_position_for_index(frame_offset.as_usize()) + delta,
|
|
0.,
|
|
);
|
|
|
|
let cursor_type = ctx.cursor_type;
|
|
let block_width = line
|
|
.item
|
|
.width_for_index(frame_offset.as_usize())
|
|
.filter(|width| *width > 0.0);
|
|
|
|
let cursor_data = CursorData {
|
|
block_width,
|
|
font_size: Some(line.item.font_size),
|
|
};
|
|
|
|
ctx.draw_and_save_cursor(
|
|
cursor_type,
|
|
cursor_position,
|
|
vec2f(styles.cursor_width, line_height(line.item)),
|
|
cursor_data,
|
|
styles,
|
|
);
|
|
}
|
|
|
|
/// Draws a background highlight over the portion of this block that overlaps with the given
|
|
/// range.
|
|
pub(super) fn draw_highlight(
|
|
&self,
|
|
start: CharOffset,
|
|
end: CharOffset,
|
|
fill: Fill,
|
|
ctx: &mut RenderContext,
|
|
buffer_max_line: LineCount,
|
|
) {
|
|
match ctx.vim_mode {
|
|
Some(VimMode::Visual(MotionType::Linewise)) => {
|
|
// For linewise visual mode, we want to highlight entire lines from the starting row to ending row.
|
|
self.draw_linewise_highlight(start, end, fill, ctx, buffer_max_line)
|
|
}
|
|
Some(VimMode::Visual(MotionType::Charwise)) => {
|
|
// Charwise visual mode should include the character under the block cursor.
|
|
self.draw_charwise_highlight(start, end + 1, fill, ctx, buffer_max_line)
|
|
}
|
|
_ => self.draw_charwise_highlight(start, end, fill, ctx, buffer_max_line),
|
|
}
|
|
}
|
|
|
|
/// Draws linewise visual selection highlighting - highlights complete lines from start row to end row
|
|
fn draw_linewise_highlight(
|
|
&self,
|
|
start: CharOffset,
|
|
end: CharOffset,
|
|
fill: Fill,
|
|
ctx: &mut RenderContext,
|
|
_buffer_max_line: LineCount,
|
|
) {
|
|
for line in self.lines() {
|
|
let line_start_within_buffer = self.buffer_index(line.item.first_index().into());
|
|
let line_end_within_buffer = self.buffer_index(line.item.end_index().into());
|
|
|
|
// Check if this line intersects with the selection range.
|
|
if line_end_within_buffer >= start && line_start_within_buffer <= end {
|
|
// Start at beginning of line, go to last char in line
|
|
let start_x = 0.0;
|
|
let end_x = line.item.width;
|
|
|
|
ctx.paint
|
|
.scene
|
|
.draw_rect_with_hit_recording(RectF::new(
|
|
ctx.content_to_screen(line.content_origin()) + vec2f(start_x, 0.),
|
|
vec2f(end_x - start_x, line_height(line.item)),
|
|
))
|
|
.with_background(fill);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Computes the x positions for a given character offset range within a line.
|
|
///
|
|
/// Returns `Some((start_x, end_x))` if the line intersects with the range,
|
|
/// or `None` if:
|
|
/// - The line starts after the end of the range (signals iteration should stop)
|
|
/// - The line doesn't intersect the range (signals this line should be skipped)
|
|
///
|
|
/// The second return value indicates whether iteration should stop entirely.
|
|
fn offsets_to_line_x_position(
|
|
&self,
|
|
line: &Positioned<'_, Line>,
|
|
start: CharOffset,
|
|
end: CharOffset,
|
|
) -> Option<(f32, f32)> {
|
|
let line_start_within_buffer = self.buffer_index(line.item.first_index().into());
|
|
let line_end_within_buffer = self.buffer_index(line.item.end_index().into());
|
|
|
|
// If the line starts after the end of the range, this and all
|
|
// following lines of the paragraph are not part of the range.
|
|
if line_start_within_buffer >= end {
|
|
return None;
|
|
}
|
|
|
|
// Skip lines that don't intersect the range
|
|
if line_end_within_buffer <= start {
|
|
return None;
|
|
}
|
|
|
|
// Clamp the start and end positions to the bounds of this specific line
|
|
let start_x = line.item.caret_position_for_index(
|
|
self.frame_index(start.max(line_start_within_buffer))
|
|
.as_usize(),
|
|
);
|
|
let end_x = line
|
|
.item
|
|
.caret_position_for_index(self.frame_index(end.min(line_end_within_buffer)).as_usize());
|
|
|
|
Some((start_x, end_x))
|
|
}
|
|
|
|
/// Draws highlighting for typical selections going charwise from start to end.
|
|
fn draw_charwise_highlight(
|
|
&self,
|
|
start: CharOffset,
|
|
end: CharOffset,
|
|
fill: Fill,
|
|
ctx: &mut RenderContext,
|
|
buffer_max_line: LineCount,
|
|
) {
|
|
for line in self.lines() {
|
|
let Some((start_x, end_x)) = self.offsets_to_line_x_position(&line, start, end) else {
|
|
let line_start_within_buffer = self.buffer_index(line.item.first_index().into());
|
|
// If line starts after range end, stop iteration
|
|
if line_start_within_buffer >= end {
|
|
break;
|
|
}
|
|
// Otherwise, skip this line
|
|
continue;
|
|
};
|
|
|
|
ctx.paint
|
|
.scene
|
|
.draw_rect_with_hit_recording(RectF::new(
|
|
ctx.content_to_screen(line.content_origin()) + vec2f(start_x, 0.),
|
|
vec2f(end_x - start_x, line_height(line.item)),
|
|
))
|
|
.with_background(fill);
|
|
|
|
let line_start_within_buffer = self.buffer_index(line.item.first_index().into());
|
|
let line_end_within_buffer = self.buffer_index(line.item.end_index().into());
|
|
let is_last_line_of_buffer =
|
|
line.start_line == buffer_max_line.saturating_sub(&LineCount(1));
|
|
let selection_crosses_newline = selection_crosses_newline_offset_based(
|
|
is_last_line_of_buffer,
|
|
start.as_usize(),
|
|
end.as_usize(),
|
|
line_start_within_buffer.as_usize(),
|
|
line_end_within_buffer.as_usize(),
|
|
);
|
|
if selection_crosses_newline {
|
|
let tick_width = calculate_tick_width(line.item.font_size);
|
|
let tick_origin =
|
|
ctx.content_to_screen(line.content_origin()) + vec2f(line.item.width, 0.);
|
|
ctx.paint
|
|
.scene
|
|
.draw_rect_with_hit_recording(create_newline_tick_rect(NewlineTickParams {
|
|
tick_origin,
|
|
tick_width,
|
|
tick_height: line_height(line.item),
|
|
}))
|
|
.with_background(fill);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Draws a dashed underline decoration over the portion of this block that overlaps with the
|
|
/// given range. Used for diagnostics.
|
|
pub(super) fn draw_dashed_underline(
|
|
&self,
|
|
start: CharOffset,
|
|
end: CharOffset,
|
|
color: ColorU,
|
|
ctx: &mut RenderContext,
|
|
) {
|
|
for line in self.lines() {
|
|
let Some((start_x, end_x)) = self.offsets_to_line_x_position(&line, start, end) else {
|
|
let line_start_within_buffer = self.buffer_index(line.item.first_index().into());
|
|
// If line starts after range end, stop iteration
|
|
if line_start_within_buffer >= end {
|
|
break;
|
|
}
|
|
// Otherwise, skip this line
|
|
continue;
|
|
};
|
|
|
|
let underline_width = end_x - start_x;
|
|
if underline_width <= 0. {
|
|
continue;
|
|
}
|
|
|
|
// Position underline at the baseline of the text
|
|
let underline_origin = ctx.content_to_screen(line.content_origin())
|
|
+ vec2f(start_x, line_height(line.item) - UNDERLINE_THICKNESS);
|
|
|
|
let underline_rect = RectF::new(
|
|
underline_origin,
|
|
vec2f(underline_width, UNDERLINE_THICKNESS),
|
|
);
|
|
|
|
let dash = galaxyui_core::scene::Dash {
|
|
dash_length: DASHED_UNDERLINE_DASH_LENGTH,
|
|
gap_length: DASHED_UNDERLINE_GAP_LENGTH,
|
|
force_consistent_gap_length: true,
|
|
};
|
|
ctx.paint
|
|
.scene
|
|
.draw_rect_without_hit_recording(underline_rect)
|
|
.with_border(
|
|
galaxyui_core::scene::Border::bottom(UNDERLINE_THICKNESS)
|
|
.with_dashed_border(dash)
|
|
.with_border_color(color),
|
|
);
|
|
}
|
|
}
|
|
|
|
fn coordinate_to_location(&self, x: Pixels, y: Pixels) -> Location {
|
|
let (line, clamped_on_y) = match (
|
|
self.lines().find(|line| line.end_y_offset() > y),
|
|
self.lines().last(),
|
|
) {
|
|
(Some(line), _) => (line.item, false),
|
|
// When the location is below the paragraph, clamp to the character matching its
|
|
// x-axis pixel position on the last line.
|
|
(None, Some(last_line)) => (last_line.item, true),
|
|
(None, None) => {
|
|
// If the paragraph is empty, clamp to the end of the paragraph.
|
|
return Location::Text {
|
|
char_offset: self.end_char_offset().saturating_sub(&1.into()),
|
|
clamped: true,
|
|
wrap_direction: WrapDirection::Up,
|
|
block_start: self.start_char_offset,
|
|
link: None,
|
|
};
|
|
}
|
|
};
|
|
|
|
let (frame_index, clamped_on_x, wrap_direction) = match line.caret_index_for_x(x.as_f32()) {
|
|
Some(index) => (FrameOffset::from(index), false, WrapDirection::Down),
|
|
None => {
|
|
// If a line contained `y`, but it does not contain `x`, clamp to
|
|
// the extremes of the line.
|
|
let frame_offset = FrameOffset::from(if x <= Pixels::zero() {
|
|
0
|
|
} else {
|
|
line.end_index()
|
|
});
|
|
(frame_offset, true, WrapDirection::Up)
|
|
}
|
|
};
|
|
|
|
// Clamp to the last character within this block (which may not be within the line).
|
|
// This prevents an issue with hit-testing on empty blocks where we would instead return
|
|
// the start of the next block.
|
|
let buffer_index = self.buffer_index(frame_index);
|
|
let end = self.end_char_offset().saturating_sub(&CharOffset::from(1));
|
|
|
|
Location::Text {
|
|
char_offset: buffer_index.min(end),
|
|
clamped: clamped_on_x || clamped_on_y,
|
|
wrap_direction,
|
|
block_start: self.start_char_offset,
|
|
link: self.link(frame_index),
|
|
}
|
|
}
|
|
|
|
fn offset_to_softwrap_point(&self, offset: CharOffset) -> SoftWrapPoint {
|
|
let Some(line) = self
|
|
.lines()
|
|
.find(|line| self.buffer_index(line.item.end_index().into()) > offset)
|
|
else {
|
|
return self.max_softwrap_point();
|
|
};
|
|
|
|
SoftWrapPoint::new(
|
|
line.start_line.as_u32(),
|
|
ColumnUnit::Pixels(
|
|
line.item
|
|
.caret_position_for_index(self.frame_index(offset).as_usize())
|
|
.into_pixels()
|
|
+ self.style.left_offset(),
|
|
),
|
|
)
|
|
}
|
|
|
|
fn softwrap_point_to_offset(&self, point: SoftWrapPoint) -> CharOffset {
|
|
let line = match self
|
|
.lines()
|
|
.find(|line| line.start_line.as_u32() >= point.row)
|
|
{
|
|
Some(line) => line.item,
|
|
None => return self.end_char_offset(),
|
|
};
|
|
|
|
let line_end = self.buffer_index(line.end_index().into());
|
|
let paragraph_last_char = self.end_char_offset().saturating_sub(&1.into());
|
|
|
|
let adjusted_x = point.column().as_pixels() - self.style.left_offset();
|
|
let frame_index = match line.caret_index_for_x(adjusted_x.as_f32()) {
|
|
Some(caret) => FrameOffset::from(caret),
|
|
None => {
|
|
// caret_index_for_x returns None if the position is out of bounds. Check which side
|
|
// it's out of bounds on to decide how to clamp.
|
|
if adjusted_x <= Pixels::zero() {
|
|
FrameOffset::from(line.first_index())
|
|
} else {
|
|
FrameOffset::from(line.end_index())
|
|
}
|
|
// Note: adjusted_x is always Pixels here because softwrap_point_to_offset
|
|
// on Positioned<Paragraph> is only called from the Pixels layout path.
|
|
}
|
|
};
|
|
|
|
let offset = self.buffer_index(frame_index);
|
|
|
|
// The upper bound on the offset depends on whether the line is soft-wrapped or hard-wrapped:
|
|
// - If soft-wrapped, it's the index just after the last character on the line
|
|
// - If hard-wrapped, it's the last non-newline character in the paragraph
|
|
// In both cases, visually this is the caret position just after the last glyph in the line.
|
|
offset.min(line_end).min(paragraph_last_char)
|
|
}
|
|
|
|
/// Converts a `CharOffset` to the corresponding character index in the text frame.
|
|
fn frame_index(&self, offset: CharOffset) -> FrameOffset {
|
|
self.item
|
|
.offsets
|
|
.to_frame(offset.saturating_sub(&self.start_char_offset))
|
|
}
|
|
|
|
/// Converts a text frame character index to the corresponding content `CharOffset`.
|
|
fn buffer_index(&self, offset: FrameOffset) -> CharOffset {
|
|
self.item.offsets.to_content(offset) + self.start_char_offset
|
|
}
|
|
|
|
fn link(&self, offset: FrameOffset) -> Option<String> {
|
|
self.item.detected_url.iter().find_map(|url| {
|
|
if url.url_range().contains(&offset.as_usize()) {
|
|
Some(url.link())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
impl<'a> Positioned<'a, ParagraphBlock> {
|
|
pub(super) fn paragraphs(&self) -> impl Iterator<Item = Positioned<'a, Paragraph>> + '_ {
|
|
self.item.paragraphs.iter().scan(
|
|
(
|
|
self.start_char_offset,
|
|
self.start_y_offset + self.style.top_offset(),
|
|
self.start_line,
|
|
),
|
|
|(char_offset_acc, y_offset_acc, line_acc), paragraph| {
|
|
let positioned = Some(Positioned {
|
|
start_y_offset: *y_offset_acc,
|
|
// Lines record their character index relative to the TextFrame start,
|
|
// so we can keep its starting offset for each.
|
|
start_char_offset: *char_offset_acc,
|
|
start_line: *line_acc,
|
|
style: self.style.without_y_axis_offsets(),
|
|
item: paragraph,
|
|
});
|
|
*char_offset_acc += paragraph.content_length;
|
|
*y_offset_acc += paragraph.height;
|
|
*line_acc += paragraph.lines();
|
|
positioned
|
|
},
|
|
)
|
|
}
|
|
|
|
/// The bounds of the first line of the first paragraph. See [`Positioned::<Paragraph>::first_line_bounds`].
|
|
fn first_line_bounds(&self) -> Option<RectF> {
|
|
self.paragraphs().next()?.first_line_bounds()
|
|
}
|
|
}
|
|
|
|
impl<'a> sum_tree::Dimension<'a, LayoutSummary> for CharOffset {
|
|
fn add_summary(&mut self, summary: &'a LayoutSummary) {
|
|
*self += summary.content_length;
|
|
}
|
|
}
|
|
|
|
impl<'a> sum_tree::Dimension<'a, LayoutSummary> for Height {
|
|
fn add_summary(&mut self, summary: &'a LayoutSummary) {
|
|
self.0 += summary.height
|
|
}
|
|
}
|
|
|
|
impl<'a> sum_tree::Dimension<'a, LayoutSummary> for Width {
|
|
fn add_summary(&mut self, summary: &'a LayoutSummary) {
|
|
*self.0 = self.0.0.max(summary.width);
|
|
}
|
|
}
|
|
|
|
impl<'a> sum_tree::Dimension<'a, LayoutSummary> for LayoutSummary {
|
|
fn add_summary(&mut self, summary: &'a LayoutSummary) {
|
|
*self += summary
|
|
}
|
|
}
|
|
|
|
impl<'a> sum_tree::Dimension<'a, LayoutSummary> for LineCount {
|
|
fn add_summary(&mut self, summary: &'a LayoutSummary) {
|
|
*self += summary.lines;
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for Paragraph {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
f.debug_struct("Paragraph")
|
|
.field("lines", &self.frame.lines().len())
|
|
.field("offsets", &self.offsets)
|
|
.field("max_width", &self.frame.max_width())
|
|
.field("height", &self.height)
|
|
.field("content_length", &self.content_length)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RenderedSelectionBias {
|
|
Left,
|
|
Right,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RenderedSelection {
|
|
/// Head is the position of the cursor.
|
|
pub head: CharOffset,
|
|
pub tail: CharOffset,
|
|
pub cursor_bias: Option<RenderedSelectionBias>,
|
|
}
|
|
|
|
impl RenderedSelection {
|
|
pub fn new(head: CharOffset, tail: CharOffset) -> Self {
|
|
Self {
|
|
head,
|
|
tail,
|
|
cursor_bias: None,
|
|
}
|
|
}
|
|
|
|
pub fn new_with_cursor_bias(
|
|
head: CharOffset,
|
|
tail: CharOffset,
|
|
bias: RenderedSelectionBias,
|
|
) -> Self {
|
|
Self {
|
|
head,
|
|
tail,
|
|
cursor_bias: Some(bias),
|
|
}
|
|
}
|
|
|
|
// Starting offset of the selection. Note that start is different from head because
|
|
// the cursor could be at either the start or end of the selection.
|
|
pub fn start(&self) -> CharOffset {
|
|
if self.head > self.tail {
|
|
self.tail
|
|
} else {
|
|
self.head
|
|
}
|
|
}
|
|
|
|
// Ending offset of the selection.
|
|
pub fn end(&self) -> CharOffset {
|
|
if self.head > self.tail {
|
|
self.head
|
|
} else {
|
|
self.tail
|
|
}
|
|
}
|
|
|
|
pub fn is_cursor(&self) -> bool {
|
|
self.head == self.tail
|
|
}
|
|
|
|
pub fn cursor_bias(&self) -> Option<RenderedSelectionBias> {
|
|
self.cursor_bias
|
|
}
|
|
|
|
// The cursor position, if this selection is a single cursor.
|
|
pub fn single_cursor(&self) -> Option<CharOffset> {
|
|
self.is_cursor().then_some(self.head)
|
|
}
|
|
}
|
|
|
|
impl Default for RenderedSelection {
|
|
fn default() -> Self {
|
|
Self {
|
|
head: CharOffset::zero(),
|
|
tail: CharOffset::zero(),
|
|
cursor_bias: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for RenderedSelection {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
write!(f, "{}..{}", self.start(), self.end())?;
|
|
if let Some(bias) = self.cursor_bias {
|
|
write!(f, " ({bias:?})")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// A set of all selections in the buffer. There must be at least
|
|
/// one selection at all times.
|
|
#[derive(Eq, PartialEq, Debug, Clone, Default)]
|
|
pub struct RenderedSelectionSet {
|
|
selections: Vec1<RenderedSelection>,
|
|
}
|
|
|
|
// Vec1 can never be empty, so we can ignore the warning to add an is_empty method.
|
|
#[allow(clippy::len_without_is_empty)]
|
|
impl RenderedSelectionSet {
|
|
pub fn iter(&self) -> impl Iterator<Item = &RenderedSelection> {
|
|
self.selections.iter()
|
|
}
|
|
|
|
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut RenderedSelection> {
|
|
self.selections.iter_mut()
|
|
}
|
|
|
|
pub fn new(selection: RenderedSelection) -> Self {
|
|
Self {
|
|
selections: Vec1::new(selection),
|
|
}
|
|
}
|
|
|
|
pub fn first(&self) -> &RenderedSelection {
|
|
self.selections.first()
|
|
}
|
|
|
|
pub fn selection_map<T, F>(&self, f: F) -> Vec1<T>
|
|
where
|
|
F: Fn(&RenderedSelection) -> T,
|
|
{
|
|
self.selections.mapped_ref(f)
|
|
}
|
|
|
|
pub fn len(&self) -> usize {
|
|
self.selections.len()
|
|
}
|
|
}
|
|
|
|
impl From<Vec1<RenderedSelection>> for RenderedSelectionSet {
|
|
fn from(selections: Vec1<RenderedSelection>) -> RenderedSelectionSet {
|
|
RenderedSelectionSet { selections }
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for RenderedSelectionSet {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
write!(f, "[")?;
|
|
for (i, selection) in self.selections.iter().enumerate() {
|
|
if i > 0 {
|
|
write!(f, ", ")?;
|
|
}
|
|
write!(f, "{selection}")?;
|
|
}
|
|
write!(f, "]")
|
|
}
|
|
}
|
|
|
|
impl IntoIterator for RenderedSelectionSet {
|
|
type Item = RenderedSelection;
|
|
type IntoIter = std::vec::IntoIter<RenderedSelection>;
|
|
|
|
fn into_iter(self) -> Self::IntoIter {
|
|
self.selections.into_iter()
|
|
}
|
|
}
|
|
|
|
impl<'a> IntoIterator for &'a RenderedSelectionSet {
|
|
type Item = &'a RenderedSelection;
|
|
type IntoIter = slice::Iter<'a, RenderedSelection>;
|
|
|
|
fn into_iter(self) -> Self::IntoIter {
|
|
self.selections.iter()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum UpdateDecorationAfterLayout {
|
|
Line(Vec<LineDecoration>),
|
|
LineAndText {
|
|
line: Vec<LineDecoration>,
|
|
text: Vec<Decoration>,
|
|
},
|
|
}
|
|
|
|
impl UpdateDecorationAfterLayout {
|
|
pub fn sort(&mut self) {
|
|
match self {
|
|
Self::Line(decorations) => {
|
|
decorations.sort_unstable_by_key(|decoration| decoration.end)
|
|
}
|
|
Self::LineAndText { line, text } => {
|
|
line.sort_unstable_by_key(|decoration| decoration.end);
|
|
text.sort_unstable_by_key(|decoration| decoration.end);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A render-time line decoration, such as highlighting the line with active cursor.
|
|
#[derive(Debug, PartialEq, Clone, Copy)]
|
|
pub struct LineDecoration {
|
|
/// Start of the decorated range (inclusive).
|
|
pub start: LineCount,
|
|
/// End of the decorated range (exclusive).
|
|
pub end: LineCount,
|
|
pub overlay: ThemeFill,
|
|
}
|
|
|
|
impl LineDecoration {
|
|
pub fn new(start: LineCount, end: LineCount, overlay: ThemeFill) -> Self {
|
|
debug_assert!(start <= end);
|
|
Self {
|
|
start,
|
|
end,
|
|
overlay,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A render-time text decoration, such as a highlighted search result.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct Decoration {
|
|
/// Start of the decorated range (inclusive).
|
|
pub start: CharOffset,
|
|
/// End of the decorated range (exclusive).
|
|
pub end: CharOffset,
|
|
pub background: Option<ThemeFill>,
|
|
/// Color for a dashed underline (used for diagnostics).
|
|
pub dashed_underline: Option<ColorU>,
|
|
}
|
|
|
|
impl Decoration {
|
|
pub fn new(start: CharOffset, end: CharOffset) -> Self {
|
|
debug_assert!(start <= end);
|
|
Self {
|
|
start,
|
|
end,
|
|
background: None,
|
|
dashed_underline: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_background(mut self, background: ThemeFill) -> Self {
|
|
self.background = Some(background);
|
|
self
|
|
}
|
|
|
|
pub fn with_dashed_underline(mut self, color: ColorU) -> Self {
|
|
self.dashed_underline = Some(color);
|
|
self
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct BrokenBlockEmbedding {
|
|
width: Pixels,
|
|
height: Pixels,
|
|
}
|
|
|
|
impl BrokenBlockEmbedding {
|
|
pub fn new(width: Pixels, font_size: f32) -> Self {
|
|
Self {
|
|
width,
|
|
height: (font_size + 2.).into_pixels(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl LaidOutEmbeddedItem for BrokenBlockEmbedding {
|
|
fn height(&self) -> Pixels {
|
|
self.height
|
|
}
|
|
|
|
fn size(&self) -> Vector2F {
|
|
vec2f(self.width.as_f32(), self.height().as_f32())
|
|
}
|
|
|
|
fn first_line_bound(&self) -> Vector2F {
|
|
vec2f(self.width.as_f32(), EMBEDDED_ITEM_FIRST_LINE_HEIGHT)
|
|
}
|
|
|
|
fn element(
|
|
&self,
|
|
state: &RenderState,
|
|
viewport_item: ViewportItem,
|
|
model: Option<&dyn EmbeddedItemModel>,
|
|
ctx: &AppContext,
|
|
) -> Box<dyn RenderableBlock> {
|
|
Box::new(RenderableBrokenEmbedding::new(
|
|
viewport_item,
|
|
state.styles(),
|
|
model,
|
|
ctx,
|
|
))
|
|
}
|
|
|
|
fn spacing(&self) -> BlockSpacing {
|
|
BROKEN_LINK_SPACING
|
|
}
|
|
|
|
fn as_any(&self) -> &dyn Any {
|
|
self
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Char-cell (TUI) layout helpers
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// The terminal display width of a character, in cells: 0 for zero-width /
|
|
/// combining marks, 2 for wide CJK/emoji, 1 otherwise. Control characters have
|
|
/// no defined width and are treated as 0.
|
|
pub fn char_cell_display_width(c: char) -> usize {
|
|
unicode_width::UnicodeWidthChar::width(c).unwrap_or(0)
|
|
}
|
|
|
|
/// For one logical line, given the display width (in cells) of each of its
|
|
/// characters, returns the 0-based char index at which each visual row begins.
|
|
/// Always returns at least `[0]`.
|
|
///
|
|
/// Width-aware: a character is placed on the current row if it fits within
|
|
/// `terminal_width` display columns; otherwise it starts a new row. Wide
|
|
/// characters that don't fit in the remaining columns wrap (leaving a blank
|
|
/// trailing cell); zero-width characters never force a wrap. With
|
|
/// `terminal_width == 0`, wrapping is disabled (a single row).
|
|
pub fn char_cell_line_row_starts(char_widths: &[u8], terminal_width: u16) -> Vec<usize> {
|
|
let w = terminal_width as usize;
|
|
let mut starts = vec![0usize];
|
|
if w == 0 {
|
|
return starts;
|
|
}
|
|
let mut col = 0usize;
|
|
for (i, &cw) in char_widths.iter().enumerate() {
|
|
let cw = cw as usize;
|
|
if col > 0 && col + cw > w {
|
|
starts.push(i);
|
|
col = 0;
|
|
}
|
|
col += cw;
|
|
}
|
|
starts
|
|
}
|
|
|
|
/// The `(row_within_line, display_col)` of the gap before char `char_in_line`
|
|
/// (or the end of the line when `char_in_line == char_widths.len()`), using the
|
|
/// same width-aware wrapping as [`char_cell_line_row_starts`]. A cursor at the
|
|
/// end of a row that exactly fills the width wraps to the start of the next row.
|
|
pub fn char_cell_line_gap_position(
|
|
char_widths: &[u8],
|
|
terminal_width: u16,
|
|
char_in_line: usize,
|
|
) -> (u32, u16) {
|
|
let w = terminal_width as usize;
|
|
let n = char_in_line.min(char_widths.len());
|
|
let mut row: u32 = 0;
|
|
let mut col: usize = 0;
|
|
for &cw in char_widths.iter().take(n) {
|
|
let cw = cw as usize;
|
|
if w > 0 && col > 0 && col + cw > w {
|
|
row += 1;
|
|
col = 0;
|
|
}
|
|
col += cw;
|
|
}
|
|
if char_in_line < char_widths.len() {
|
|
// The cursor sits before char `char_in_line`; if that char would wrap,
|
|
// the cursor is at the start of the next row.
|
|
let cw = char_widths[char_in_line] as usize;
|
|
if w > 0 && col > 0 && col + cw > w {
|
|
row += 1;
|
|
col = 0;
|
|
}
|
|
} else if w > 0 && col == w {
|
|
// End-of-line cursor on a row that exactly fills the width wraps to the
|
|
// next row's start (matches plain monospace terminal behavior).
|
|
row += 1;
|
|
col = 0;
|
|
}
|
|
(row, col as u16)
|
|
}
|
|
|
|
/// The number of visual rows occupied by a single logical line (always >= 1).
|
|
fn char_cell_line_rows(char_widths: &[u8], terminal_width: u16) -> u32 {
|
|
char_cell_line_row_starts(char_widths, terminal_width).len() as u32
|
|
}
|
|
|
|
/// The `\n`-free slice of per-char display widths for logical line `i`, given
|
|
/// the line-start indices and the full per-char width buffer.
|
|
fn char_cell_logical_line<'a>(line_starts: &[usize], char_widths: &'a [u8], i: usize) -> &'a [u8] {
|
|
let start = line_starts[i].min(char_widths.len());
|
|
// The next line starts just after this line's '\n'; exclude that newline.
|
|
let end = line_starts
|
|
.get(i + 1)
|
|
.map(|&next| next.saturating_sub(1))
|
|
.unwrap_or(char_widths.len())
|
|
.min(char_widths.len());
|
|
&char_widths[start..end.max(start)]
|
|
}
|
|
|
|
/// Returns the total number of visual rows across all logical lines.
|
|
/// Used by [`RenderState::max_line`] in `CharCell` mode.
|
|
pub(crate) fn char_cell_max_line(
|
|
line_starts: &[usize],
|
|
char_widths: &[u8],
|
|
terminal_width: u16,
|
|
) -> LineCount {
|
|
if line_starts.is_empty() {
|
|
return LineCount(1);
|
|
}
|
|
let mut total: usize = 0;
|
|
for i in 0..line_starts.len() {
|
|
let line = char_cell_logical_line(line_starts, char_widths, i);
|
|
total += char_cell_line_rows(line, terminal_width) as usize;
|
|
}
|
|
LineCount(total)
|
|
}
|
|
|
|
/// Converts a 0-based character index to a [`SoftWrapPoint`] in char-cell coordinates.
|
|
///
|
|
/// This softwrap API is 0-based — index 0 is the first character — matching the
|
|
/// convention the navigation callers already use for both layout modes: they pass
|
|
/// `cursor_offset - 1` here and re-add 1 to [`char_cell_softwrap_point_to_offset`]
|
|
/// results to convert back to the buffer's 1-based [`CharOffset`]. Keeping both modes
|
|
/// on the same contract is what lets `navigate_line` stay layout-mode-agnostic.
|
|
pub(crate) fn char_cell_offset_to_softwrap_point(
|
|
offset: CharOffset,
|
|
line_starts: &[usize],
|
|
char_widths: &[u8],
|
|
terminal_width: u16,
|
|
) -> SoftWrapPoint {
|
|
let char_idx = offset.as_usize();
|
|
|
|
// Find the logical line by binary-searching line_starts.
|
|
let logical_line = line_starts
|
|
.partition_point(|&s| s <= char_idx)
|
|
.saturating_sub(1);
|
|
let line_start_char = line_starts.get(logical_line).copied().unwrap_or(0);
|
|
let char_in_line = char_idx.saturating_sub(line_start_char);
|
|
|
|
// Count visual rows from all preceding logical lines.
|
|
let mut preceding_rows: u32 = 0;
|
|
for i in 0..logical_line {
|
|
let line = char_cell_logical_line(line_starts, char_widths, i);
|
|
preceding_rows += char_cell_line_rows(line, terminal_width);
|
|
}
|
|
|
|
let line = char_cell_logical_line(line_starts, char_widths, logical_line);
|
|
let (row_within_line, col) = char_cell_line_gap_position(line, terminal_width, char_in_line);
|
|
SoftWrapPoint::new(preceding_rows + row_within_line, ColumnUnit::Chars(col))
|
|
}
|
|
|
|
/// Converts a [`SoftWrapPoint`] in char-cell coordinates back to a 0-based character
|
|
/// index — the inverse of [`char_cell_offset_to_softwrap_point`]. Callers re-add 1 to
|
|
/// recover the buffer's 1-based [`CharOffset`].
|
|
///
|
|
/// The result is always clamped to the end of the logical line it lands in (the
|
|
/// final line is bounded by the buffer length), so it never returns an offset
|
|
/// past the end of the buffer even when the target column is beyond a shorter
|
|
/// final line.
|
|
pub(crate) fn char_cell_softwrap_point_to_offset(
|
|
point: SoftWrapPoint,
|
|
line_starts: &[usize],
|
|
char_widths: &[u8],
|
|
terminal_width: u16,
|
|
) -> CharOffset {
|
|
let target_row = point.row();
|
|
// Accept either variant: Chars is the normal CharCell column; Pixels is produced
|
|
// by GUI-path navigation helpers (e.g. navigate_line_boundary) that hard-code
|
|
// ColumnUnit::pixels_zero() to mean "start of row". Treat any Pixels value as 0.
|
|
let target_col = match point.column() {
|
|
ColumnUnit::Chars(c) => c as usize,
|
|
ColumnUnit::Pixels(_) => 0,
|
|
};
|
|
|
|
if line_starts.is_empty() {
|
|
return CharOffset::from(0);
|
|
}
|
|
|
|
let mut acc_rows: u32 = 0;
|
|
for i in 0..line_starts.len() {
|
|
let line_start = line_starts[i];
|
|
let line = char_cell_logical_line(line_starts, char_widths, i);
|
|
let row_starts = char_cell_line_row_starts(line, terminal_width);
|
|
let line_rows = row_starts.len() as u32;
|
|
let is_last = i + 1 == line_starts.len();
|
|
|
|
// The target row falls in this line, or this is the last line (which
|
|
// absorbs any overshoot so the result never exceeds the buffer).
|
|
if acc_rows + line_rows > target_row || is_last {
|
|
let row_within =
|
|
(target_row.saturating_sub(acc_rows) as usize).min(row_starts.len() - 1);
|
|
let row_start_char = row_starts[row_within];
|
|
let row_end_char = row_starts
|
|
.get(row_within + 1)
|
|
.copied()
|
|
.unwrap_or(line.len());
|
|
|
|
// Walk the row's per-char widths to find the gap at or just before
|
|
// `target_col`, clamped to the row's end (which never spills past
|
|
// the logical line, and for the final line, past the buffer).
|
|
let mut col = 0usize;
|
|
let mut idx = row_start_char;
|
|
while idx < row_end_char {
|
|
let cw = line[idx] as usize;
|
|
if col + cw > target_col {
|
|
break;
|
|
}
|
|
col += cw;
|
|
idx += 1;
|
|
}
|
|
return CharOffset::from(line_start + idx);
|
|
}
|
|
|
|
acc_rows += line_rows;
|
|
}
|
|
|
|
// Unreachable given the `is_last` branch always returns; fall back to the
|
|
// start of the last logical line.
|
|
CharOffset::from(*line_starts.last().unwrap_or(&0))
|
|
}
|