diff --git a/Cargo.lock b/Cargo.lock index d0d42eae..882bf7da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2783,6 +2783,13 @@ version = "0.0.0" name = "parley_dev" version = "0.0.0" +[[package]] +name = "parley_flow" +version = "0.0.0" +dependencies = [ + "parley", +] + [[package]] name = "paste" version = "1.0.15" diff --git a/Cargo.toml b/Cargo.toml index 9d3b3e03..fa8ae091 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "parley_bench", "parley_core", "parley_dev", + "parley_flow", "examples/tiny_skia_render", "examples/swash_render", "examples/vello_editor", diff --git a/parley_flow/Cargo.toml b/parley_flow/Cargo.toml new file mode 100644 index 00000000..855d4ec6 --- /dev/null +++ b/parley_flow/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "parley_flow" +description = "High-level text surfaces, multi-selection, and document aggregation on top of parley" +keywords = ["text", "layout", "flow"] +categories = ["gui", "graphics"] +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +exclude = ["/tests"] +readme = "README.md" + +[package.metadata.docs.rs] +all-features = true + +[lints] +workspace = true + +[features] +default = ["std"] +std = ["parley/std"] +libm = ["parley/libm"] + +[dependencies] +parley = { workspace = true } diff --git a/parley_flow/README.md b/parley_flow/README.md new file mode 100644 index 00000000..8174d1ae --- /dev/null +++ b/parley_flow/README.md @@ -0,0 +1,13 @@ +# parley_flow (TextBlock + TextFlow) + +A small, flow-first layer on top of Parley that introduces: + +- TextBlock: a minimal trait for blocks of laid-out text (paragraphs, labels) +- LayoutBlock: adapter for a `parley::layout::Layout` + `&str` +- TextFlow: explicit ordered containers (rect + join policy) for deterministic hit-testing, + cross-block navigation, and text concatenation +- Flow-based helpers: `hit_test`, `selection_geometry`, `copy_text` + +Status: experimental. Names and APIs may change as this evolves. + +See `src/design.rs` for comparisons to TextKit/DirectWrite/Android and long-term goals. diff --git a/parley_flow/examples/basic.rs b/parley_flow/examples/basic.rs new file mode 100644 index 00000000..93248cc6 --- /dev/null +++ b/parley_flow/examples/basic.rs @@ -0,0 +1,74 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Minimal example showing surfaces, multi-selection geometry, and copy. + +use parley::{Alignment, AlignmentOptions, FontContext, Layout, LayoutContext, StyleProperty}; +use parley_flow::{ + BoundaryPolicy, LayoutBlock, SelectionSegment, SelectionSet, copy_text, flow::TextFlow, + hit_test, selection_geometry, +}; + +fn build_layout( + font_cx: &mut FontContext, + layout_cx: &mut LayoutContext<()>, + text: &str, +) -> Layout<()> { + let mut builder = layout_cx.ranged_builder(font_cx, text, 1.0, true); + builder.push_default(StyleProperty::FontSize(16.0)); + let mut layout: Layout<()> = builder.build(text); + let width = Some(200.0); + layout.break_all_lines(width); + layout.align(width, Alignment::Start, AlignmentOptions::default()); + layout +} + +fn main() { + // Build two simple paragraph layouts with Parley + let mut font_cx = FontContext::new(); + let mut layout_cx = LayoutContext::new(); + + let text1 = "Hello world"; + let text2 = "Second line"; + let layout1 = build_layout(&mut font_cx, &mut layout_cx, text1); + let layout2 = build_layout(&mut font_cx, &mut layout_cx, text2); + + // Wrap them as surfaces with y-offsets stacked vertically + let surfaces = vec![ + LayoutBlock { + id: 0_u32, + layout: &layout1, + text: text1, + }, + LayoutBlock { + id: 1_u32, + layout: &layout2, + text: text2, + }, + ]; + let flow = TextFlow::::from_vertical_stack::<(), _>(&surfaces, BoundaryPolicy::Newline); + + // Hit-test near the start of the first paragraph to get a caret + let caret = hit_test::<(), _>(&flow, &surfaces, 2.0, 2.0).expect("caret"); + println!("Caret: {:?}", caret); + + // Build a multi-selection spanning both surfaces + let mut set = SelectionSet::collapsed(caret); + set.add_segment(SelectionSegment::new(0, 0..5)); // "Hello" + set.add_segment(SelectionSegment::new(1, 0..6)); // "Second" + + // Compute geometry (global coordinates) + let mut rects = Vec::new(); + selection_geometry::<(), _, _>(&flow, &surfaces, &set, |bb, _| rects.push((bb, 0))); + println!("Selection rects (count={}):", rects.len()); + for (bb, surface_ix) in &rects { + println!( + " surface={} rect=({},{})->({},{}))", + surface_ix, bb.x0, bb.y0, bb.x1, bb.y1 + ); + } + + // Extract text across surfaces + let copied = copy_text::<(), _>(&flow, &surfaces, &set); + println!("Copied text:\n{}", copied); +} diff --git a/parley_flow/src/block.rs b/parley_flow/src/block.rs new file mode 100644 index 00000000..efd2ea8c --- /dev/null +++ b/parley_flow/src/block.rs @@ -0,0 +1,108 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Text blocks and a simple adapter for Parley [`parley::layout::Layout`]. + +use alloc::string::String; +use core::ops::Range; + +use parley::layout::Layout; +use parley::style::Brush; + +/// A uniform facade for anything that behaves like a text layout block. +/// +/// Implementers must provide: +/// - A stable identifier (`id`). +/// - A layout reference for geometry and hit-testing. +/// - Text access for extraction via [`TextBlock::text_slice`] and/or +/// [`TextBlock::read_text`]. +/// +/// Geometry and ordering are defined by `FlowItem` rectangles in a `TextFlow`; this +/// trait does not carry positional information. +pub trait TextBlock { + /// Identifier type used by this surface set. + type Id: Copy + Ord + Eq + core::fmt::Debug; + + /// Stable identifier for the surface. + fn id(&self) -> Self::Id; + + /// The underlying Parley layout for this surface. + /// + /// This is typically a [`parley::layout::Layout`] built by your code. + fn layout(&self) -> &Layout; + + /// Return a borrowed text slice for a local byte `range`, if valid and contiguous. + /// + /// This is the fast path used when the underlying storage is contiguous. Implementers that + /// do not have contiguous storage (e.g., ropes) can return `None` and instead implement + /// [`TextBlock::read_text`]. The `range` must be on UTF‑8 character boundaries. + fn text_slice(&self, _range: Range) -> Option<&str> { + None + } + + /// Append the text in the local byte `range` into `out` and return `true` if successful. + /// + /// This is the fallback used by helpers like [`crate::copy_text`] to support non‑contiguous + /// storage. The default implementation tries [`TextBlock::text_slice`] and, if present, + /// pushes it into `out`. + fn read_text(&self, range: Range, out: &mut String) -> bool { + if let Some(s) = self.text_slice(range) { + out.push_str(s); + true + } else { + false + } + } +} + +/// A simple adapter turning a Parley [`parley::layout::Layout`] and source text into a +/// [`TextBlock`]. +/// +/// Construct this when you already have a `Layout` and the `&str` it was built from, then +/// pass it to helpers like [`crate::hit_test`], [`crate::selection_geometry`], and +/// [`crate::copy_text`]. +#[derive(Copy, Clone)] +pub struct LayoutBlock<'a, B: Brush, Id> { + /// Stable identifier for the surface. + pub id: Id, + /// The layout to expose. + pub layout: &'a Layout, + /// The source text used to build `layout`. + pub text: &'a str, +} + +impl<'a, B: Brush, Id: Copy + Ord + Eq + core::fmt::Debug> TextBlock for LayoutBlock<'a, B, Id> { + type Id = Id; + + fn id(&self) -> Self::Id { + self.id + } + + fn layout(&self) -> &Layout { + self.layout + } + + fn text_slice(&self, range: Range) -> Option<&str> { + self.text.get(range) + } + + fn read_text(&self, range: Range, out: &mut String) -> bool { + if let Some(slice) = self.text.get(range) { + out.push_str(slice); + true + } else { + false + } + } +} + +impl<'a, B: Brush, Id: Copy + Ord + Eq + core::fmt::Debug> core::fmt::Debug + for LayoutBlock<'a, B, Id> +{ + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("LayoutBlock") + .field("id", &self.id) + .field("text_len", &self.text.len()) + .finish_non_exhaustive() + } +} diff --git a/parley_flow/src/design.rs b/parley_flow/src/design.rs new file mode 100644 index 00000000..c1feb3f5 --- /dev/null +++ b/parley_flow/src/design.rs @@ -0,0 +1,154 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Design notes, comparisons, and long-term considerations. +//! +//! Parley’s core (`parley`) is intentionally paragraph-focused and pure: it owns shaping, +//! line breaking, bidi, and alignment. This crate builds a higher-level layer for composing +//! many paragraphs/surfaces and for modeling multi-selection/navigation. +//! +//! ## Inspiration and Comparisons +//! +//! ### Apple TextKit 1/2 +//! +//! - Layering: `NSTextStorage` (attributed storage) → `NSLayoutManager`/`NSTextLayoutManager` +//! (shaping/line breaking) → `NSTextContainer` (geometry/regions). Storage can flow through +//! multiple containers for columns/pages. +//! - Selection/navigation: First-class objects (`NSTextSelection`, `NSTextSelectionNavigation`) +//! with anchor/focus, affinity, and granularity (character/word/line/paragraph). +//! - Attachments: `NSTextAttachment` uses object-replacement semantics, which maps to Parley +//! inline boxes and helps serialization (U+FFFC placeholder). +//! - Takeaways for us: +//! - Preserve a strict separation: storage vs. layout vs. region. +//! - Model selection/navigation as reusable services that operate over regions/surfaces. +//! - Treat inline objects as real selection units and serialization boundaries. +//! +//! ### Windows DirectWrite + TSF +//! +//! - DirectWrite: paragraph-centric layouts (`IDWriteTextLayout`) composed by host toolkits +//! into documents. Justification, trimming, and typographic knobs exposed as explicit options. +//! - TSF (Text Services Framework): composition (IME) modeled as ranges; selection kept in sync; +//! UTF‑16 code unit indexing as lingua franca. +//! - Takeaways: +//! - Keep layout per-paragraph; composition/selection are ranges in storage. +//! - Provide explicit UTF‑8↔UTF‑16 conversions at the edges for platform interoperability. +//! - Don’t bake editing into layout; keep layout pure and reusable. +//! +//! ### Android (Spannable, MovementMethod, InputConnection) +//! +//! - Ranges: Spans with inclusive/exclusive flags survive edits; replacement spans for attachments. +//! - Navigation: MovementMethod separates navigation from widgets, enabling reuse across views. +//! - IME: InputConnection is an explicit bridge for composition, commit, and selection updates. +//! - Precompute: Precomputed/Measured text validates doing shaping/measurement off the UI thread +//! and reusing results (like `LayoutContext`). +//! - Cautions: span proliferation and watchers can become hot paths; cross-widget selection is +//! not first-class. +//! - Takeaways: +//! - Favor compact range tables over callback-heavy span objects. +//! - Keep navigation and IME bridges separate, explicit, and host-driven. +//! - Make async/precomputed layout a supported pattern via caches + generations. +//! +//! ### WPF/Web (brief) +//! +//! - WPF: `TextContainer`/`TextPointer`/`TextSelection` abstractions, flow across regions. +//! - Web: `Range` across nodes, selection APIs that treat node boundaries as hard breaks; rich +//! serialization policies matter. +//! - Takeaways: +//! - Encapsulate positions as abstract pointers, not raw indices. +//! - Provide serialization policies (logical/visual order, boundary separators). +//! +//! ## Key Choices for a 10+ Year Horizon +//! +//! - Keep editor/navigation policies out of `Layout`; implement them in this crate. +//! - Favor explicit types for locations/ranges across surfaces, with conversions to platform units. +//! - Treat surface boundaries as hard boundaries for movement/word/line granularity. +//! - Provide read-only aggregation (copy/search/AX) across surfaces by default; editing across +//! multiple surfaces is opt-in and typically limited to a single active caret. +//! +//! ## Future Work +//! +//! ### Surface Flow and Ordering +//! +//! Parley surfaces are intentionally minimal. Explicit flow is modeled by `flow::TextFlow`: +//! - Each `FlowItem` encodes a `block_id`, a `rect` (for hit-testing/geometry), and a `join` +//! policy for serialization. +//! - Order in the `TextFlow` defines cross-block navigation and concatenation semantics. +//! - This mirrors TextKit’s ordered container array. +//! +//! Guidance: +//! - Provide non-overlapping `rect`s in the flow for deterministic hit-testing. +//! - Use large widths/heights when you don’t care about precise bounds; the flow determines order. +//! - For multi-column/page or virtualized layouts, build the `TextFlow` with the intended reading +//! order and rects for each visible block. +//! - Concatenation: use a uniform `join` via [`crate::flow::TextFlow::from_vertical_stack`] or assign +//! per-item `join` policies (e.g., `Space` for inline, `Newline` for block boundaries). +//! +//! ### 1) TextLocation and TextRange (positions across surfaces) +//! +//! - Purpose: decouple pointer/range representations from raw indices; enable safe conversions to +//! platform units and stable identity across edits. +//! - Shape: +//! - `TextLocation { surface_id, utf8: usize }` with helpers: `to_utf16()`, `from_utf16()`. +//! - `TextRange { start: TextLocation, end: TextLocation }`, normalized with methods for +//! granularity expansion (to cluster/word/line/paragraph) using the surface’s layout. +//! - Invariants: +//! - Always at character boundaries in UTF‑8; conversion to UTF‑16 is lossy only in units, not +//! in meaning. +//! - Stable `surface_id` and monotonic ordering within a surface. +//! - Interop: +//! - Map to TSF/AppKit APIs that require UTF‑16 indices; provide zero‑allocation conversions. +//! - Serialize as absolute (surface_id, byte_offset) to avoid ambiguity when text changes. +//! - Integration: +//! - Backed by the same hit-testing code as current `Cursor`/`Selection`. +//! - Acts as the wire type for accessibility and IME bridges. +//! +//! ### 2) SelectionNavigation (surface‑crossing caret movement) +//! +//! - Purpose: unify navigation semantics (move/extend by cluster/word/line/paragraph) across +//! multiple surfaces in visual order, mirroring Apple’s `NSTextSelectionNavigation`. +//! - Current scaffold: +//! - Implemented in `crate::selection_navigation` with `move_left`/`move_right` that cross +//! surface boundaries by jumping to the end/start of adjacent surfaces. +//! - Inside a surface, movement delegates to Parley’s `Cursor` logic. +//! - Tests cover crossing from the end of one surface to the start of the next, and vice versa. +//! - Next steps: +//! - Vertical movement preserving `h_pos`: `move_line_up`/`move_line_down`. +//! - Word/paragraph granularity: `move_word_left/right`, `hard_line_start/end`. +//! - Extend variants (Shift-modify): introduce an anchor caret in `SelectionSet` or pass a +//! transient anchor so movement can grow/shrink the nearest segment or add cross-surface +//! segments. +//! - Surface ordering predicates: allow clients to supply ordering beyond y-offset (e.g., columns). +//! - Semantics to retain: +//! - Affinity and bidi: respect `Cursor` affinity at line ends; treat surface edges as hard +//! boundaries; never span a cluster across surfaces. +//! - Vertical movement: maintain a sticky `h_pos` in global coordinates; when moving across +//! surfaces, compute target y in the next surface via its `y_offset` and line metrics. +//! - Word/line boundaries: use per-surface rules; when crossing a surface, land at the first/last +//! selectable boundary inside the target surface. +//! - Testing: +//! - Golden tests for bidi line ends, RTL/LTR boundaries, mixed metrics; property tests for +//! inverse moves where appropriate. +//! +//! ### 3) Document Storage and Incremental Relayout +//! +//! - Purpose: provide a concrete “document” built from paragraphs with efficient editing, +//! invalidation, and relayout, while still exposing each paragraph as a `TextBlock`. +//! - Storage: +//! - Use a rope or gap buffer for large texts; maintain paragraph boundaries as a side table of +//! byte ranges with stable paragraph IDs. +//! - Inclusive/exclusive range flags for styles, composition, and annotations that survive edits. +//! - Invalidation: +//! - When editing, detect impacted paragraphs (splits/merges on newlines) and only rebuild +//! affected layouts; reuse shaped results when attributes allow. +//! - Employ generations to coordinate caches (fonts, shaping, line breaking) and async rebuilds. +//! - Layout: +//! - Build each paragraph’s `Layout` via `LayoutContext`; cache glyph/cluster data. +//! - Support async layout: produce placeholder metrics/geometry, swap in final results when ready. +//! - Regions: +//! - Flow paragraphs into regions (columns/pages) by assigning `y_offset`s; each paragraph is a +//! `TextBlock` with its own layout and text slice. +//! - Attachments: +//! - Represent as inline boxes using object‑replacement semantics (U+FFFC) for selection and +//! serialization; geometry carried by the inline box. +//! +//! This module contains only documentation. diff --git a/parley_flow/src/flow.rs b/parley_flow/src/flow.rs new file mode 100644 index 00000000..318c97fc --- /dev/null +++ b/parley_flow/src/flow.rs @@ -0,0 +1,117 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Explicit flow of text blocks (containers), inspired by TextKit's container array. + +use alloc::vec::Vec; +use parley::BoundingBox; + +use crate::TextBlock; +use crate::multi_selection::BoundaryPolicy; +use parley::style::Brush; + +/// A single container in the text flow. +#[derive(Clone, Copy, PartialEq)] +pub struct FlowItem { + /// Identifier of the text block this container references. + pub id: Id, + /// Container bounds in global coordinates. + pub rect: BoundingBox, + /// Separator policy to apply after this container when concatenating text. + pub join: BoundaryPolicy, +} + +impl FlowItem { + /// Create a new flow item with the given block `id`, global container `rect`, and `join` + /// policy to apply when concatenating text after this item. + pub fn new(id: Id, rect: BoundingBox, join: BoundaryPolicy) -> Self { + Self { id, rect, join } + } +} + +impl core::fmt::Debug for FlowItem { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("FlowItem") + .field("id", &self.id) + .field("join", &self.join) + .finish_non_exhaustive() + } +} + +/// An ordered list of containers defining hit-testing, navigation order, and join behavior. +#[derive(Clone, Debug, Default)] +pub struct TextFlow { + items: Vec>, +} + +impl TextFlow { + /// Create a flow from an explicit `items` vector. + pub fn new(items: Vec>) -> Self { + Self { items } + } + + /// Read-only access to the flow items. + pub fn items(&self) -> &[FlowItem] { + &self.items + } + + /// Returns the index of the flow item with the given `id`. + pub fn index_of(&self, id: Id) -> Option { + self.items.iter().position(|it| it.id == id) + } + + /// Returns the previous block `id` in flow order. + pub fn prev_id(&self, id: Id) -> Option { + let ix = self.index_of(id)?; + ix.checked_sub(1).map(|i| self.items[i].id) + } + + /// Returns the next block `id` in flow order. + pub fn next_id(&self, id: Id) -> Option { + let ix = self.index_of(id)?; + (ix + 1 < self.items.len()).then(|| self.items[ix + 1].id) + } + + /// Returns the block `id` whose rect contains the point, if any. + pub fn hit_test(&self, x: f32, y: f32) -> Option { + let x = x as f64; + let y = y as f64; + self.items + .iter() + .find(|it| { + let r = it.rect; + x >= r.x0 && x < r.x1 && y >= r.y0 && y < r.y1 + }) + .map(|it| it.id) + } + + /// Returns the join policy to use after the item identified by `id`. + pub fn join_after(&self, id: Id) -> BoundaryPolicy { + if let Some(ix) = self.index_of(id) { + self.items[ix].join + } else { + BoundaryPolicy::Space + } + } + + /// Convenience: build a vertical-flow from a list of blocks. + /// + /// Each block contributes one [`FlowItem`] with a rect that stacks blocks top-to-bottom + /// starting at y=0 with no gaps. The height and width are taken from each block’s layout. + /// The `join` policy is applied to all items. + pub fn from_vertical_stack(blocks: &[S], join: BoundaryPolicy) -> Self + where + S: TextBlock, + { + let mut items = Vec::with_capacity(blocks.len()); + let mut y0 = 0.0_f64; + for b in blocks { + let layout = b.layout(); + let rect = + BoundingBox::new(0.0, y0, layout.width() as f64, y0 + layout.height() as f64); + items.push(FlowItem::new(b.id(), rect, join)); + y0 += layout.height() as f64; + } + Self::new(items) + } +} diff --git a/parley_flow/src/lib.rs b/parley_flow/src/lib.rs new file mode 100644 index 00000000..be1b4327 --- /dev/null +++ b/parley_flow/src/lib.rs @@ -0,0 +1,227 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! `parley_flow`: text blocks, flow, and multi-selection on top of Parley. +//! +//! This crate adds a small, reusable layer over Parley’s per-paragraph [`parley::layout::Layout`]: +//! - [`TextBlock`]: a uniform facade for blocks of text (labels, +//! paragraphs, document fragments) that expose layout and text access. +//! - [`LayoutBlock`]: a thin adapter that turns a [`parley::layout::Layout`] and its source `&str` +//! into a [`TextBlock`]. +//! - [`flow::TextFlow`]: an explicit list of containers (order + rect + separator) for deterministic +//! hit-testing, navigation, and text concatenation. +//! - Multi-selection types: [`Caret`], [`SelectionSegment`], and [`SelectionSet`]. +//! +//! Design background and platform comparisons are collected in [`design`]; crate docs focus on API. +//! +//! Quick usage outline: +//! - Build your paragraphs with Parley as usual to get a [`parley::layout::Layout`]. +//! - Wrap each paragraph as a [`LayoutBlock`], providing a stable `id` and `y_offset`. +//! - Build a [`flow::TextFlow`] with one [`flow::FlowItem`] per block (rect + join policy). +//! - Hit-test with [`hit_test`] to obtain a [`Caret`], then create a +//! [`SelectionSet::collapsed`] around it or add [`SelectionSegment`]s directly. +//! - Render selection boxes with [`selection_geometry`] and extract text with +//! [`copy_text`]. +//! +//! ## Flow and Ordering +//! +//! This crate uses an explicit flow (inspired by TextKit’s container array): +//! - Hit-testing consults the flow’s container rects directly; no heuristics. +//! - Navigation order across blocks follows the flow’s item order. +//! - Text concatenation between adjacent blocks uses each item’s `join` policy. +//! Use [`flow::TextFlow::from_vertical_stack`] for a uniform separator, or assign `join` +//! per item when you need mixed separators (inline vs block) in the same flow. +//! +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +use alloc::string::String; + +use parley::BoundingBox; +use parley::editing::{Cursor, Selection}; +use parley::style::Brush; + +mod block; +pub use block::{LayoutBlock, TextBlock}; + +mod multi_selection; +pub use multi_selection::{BoundaryPolicy, Caret, SelectionSegment, SelectionSet}; + +/// Design notes and platform comparisons. +pub mod design; + +/// Explicit flow of text blocks (containers). +pub mod flow; +/// Navigation helpers for moving the active caret across blocks. +pub mod navigation; + +// Flow-based hit testing and geometry utilities are below. + +/// Hit-test with a [`flow::TextFlow`], returning a [`Caret`] at the corresponding block. +#[allow( + clippy::cast_possible_truncation, + reason = "Layout coordinates are f32; flow rects are f64; truncation is acceptable when mapping to local layout space." +)] +pub fn hit_test<'a, B, S>( + flow: &flow::TextFlow, + blocks: &'a [S], + x: f32, + y: f32, +) -> Option> +where + B: Brush, + S: TextBlock + 'a, +{ + let id = flow.hit_test(x, y)?; + let item = flow.items().iter().find(|it| it.id == id)?; + let block = blocks.iter().find(|b| b.id() == id)?; + // Map to local coordinates using flow rect + let local_x = x - item.rect.x0 as f32; + let local_y = y - item.rect.y0 as f32; + let cursor = Cursor::from_point(block.layout(), local_x, local_y); + Some(Caret { + surface: id, + cursor, + h_pos: None, + }) +} + +/// Compute selection rectangles across blocks with a [`flow::TextFlow`]. +/// +/// Each rectangle is offset by the corresponding flow item's rect (x and y) to global space. +pub fn selection_geometry( + flow: &flow::TextFlow, + blocks: &[S], + set: &SelectionSet, + mut f: F, +) where + B: Brush, + S: TextBlock, + F: FnMut(BoundingBox, S::Id), +{ + for seg in &set.segments { + let Some(item) = flow.items().iter().find(|it| it.id == seg.surface) else { + continue; + }; + let Some(block) = blocks.iter().find(|b| b.id() == seg.surface) else { + continue; + }; + let sel = Selection::new( + Cursor::from_byte_index::(block.layout(), seg.range.start, seg.anchor_affinity), + Cursor::from_byte_index::(block.layout(), seg.range.end, seg.focus_affinity), + ); + sel.geometry_with::(block.layout(), |bb, _line| { + let g = BoundingBox::new( + bb.x0 + item.rect.x0, + bb.y0 + item.rect.y0, + bb.x1 + item.rect.x0, + bb.y1 + item.rect.y0, + ); + f(g, seg.surface); + }); + } +} + +/// Extract selected text across blocks using a [`flow::TextFlow`]. +pub fn copy_text( + flow: &flow::TextFlow, + blocks: &[S], + set: &SelectionSet, +) -> String +where + B: Brush, + S: TextBlock, +{ + let mut out = String::new(); + let mut prev_id: Option = None; + for seg in &set.segments { + if let Some(pid) = prev_id.filter(|p| *p != seg.surface) { + match flow.join_after(pid) { + BoundaryPolicy::None => {} + BoundaryPolicy::Space => out.push(' '), + BoundaryPolicy::Newline => out.push('\n'), + } + } + if let Some(block) = blocks.iter().find(|b| b.id() == seg.surface) { + if !block.read_text(seg.range.clone(), &mut out) { + if let Some(slice) = block.text_slice(seg.range.clone()) { + out.push_str(slice); + } + } + } + prev_id = Some(seg.surface); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use parley::{Alignment, AlignmentOptions, FontContext, Layout, LayoutContext, StyleProperty}; + + fn build_layout( + font_cx: &mut FontContext, + layout_cx: &mut LayoutContext<()>, + text: &str, + ) -> Layout<()> { + let mut builder = layout_cx.ranged_builder(font_cx, text, 1.0, true); + builder.push_default(StyleProperty::FontSize(14.0)); + let mut layout: Layout<()> = builder.build(text); + let width = Some(200.0); + layout.break_all_lines(width); + layout.align(width, Alignment::Start, AlignmentOptions::default()); + layout + } + + #[test] + fn hit_test_and_copy_text() { + let mut font_cx = FontContext::new(); + let mut layout_cx = LayoutContext::new(); + let text1 = "Hello"; + let text2 = "World"; + let l1 = build_layout(&mut font_cx, &mut layout_cx, text1); + let l2 = build_layout(&mut font_cx, &mut layout_cx, text2); + let l1: &'static Layout<()> = Box::leak(Box::new(l1)); + let l2: &'static Layout<()> = Box::leak(Box::new(l2)); + let blocks = vec![ + LayoutBlock { + id: 1_u32, + layout: l1, + text: text1, + }, + LayoutBlock { + id: 2_u32, + layout: l2, + text: text2, + }, + ]; + // Build explicit rects to ensure reliable hit-testing + let flow = flow::TextFlow::new(vec![ + flow::FlowItem::new( + 1, + BoundingBox::new(0.0, 0.0, 1_000_000.0, (l1.height() + 2.0) as f64), + BoundaryPolicy::Space, + ), + flow::FlowItem::new( + 2, + BoundingBox::new( + 0.0, + (l1.height() + 4.0) as f64, + 1_000_000.0, + (l1.height() + 4.0 + l2.height()) as f64, + ), + BoundaryPolicy::Space, + ), + ]); + // Hit-test in the first block + let caret = hit_test::<(), _>(&flow, &blocks, 1.0, 0.1).expect("hit"); + assert_eq!(caret.surface, 1); + // Copy text across blocks + let mut set = SelectionSet::default(); + set.add_segment(SelectionSegment::new(1, 0..text1.len())); + set.add_segment(SelectionSegment::new(2, 0..text2.len())); + let copied = copy_text::<(), _>(&flow, &blocks, &set); + assert_eq!(copied, "Hello World"); + } +} diff --git a/parley_flow/src/multi_selection.rs b/parley_flow/src/multi_selection.rs new file mode 100644 index 00000000..2a86552c --- /dev/null +++ b/parley_flow/src/multi_selection.rs @@ -0,0 +1,181 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Multi-surface, discontiguous selection types. +//! +//! These types represent selections that can span multiple [`TextBlock`](crate::TextBlock)s. +//! Typical flow: +//! - Use [`crate::hit_test`] to obtain a [`Caret`] from a global point. +//! - Create a collapsed set with [`SelectionSet::collapsed`], or add [`SelectionSegment`]s for +//! ranges you want selected. +//! - Render with [`crate::selection_geometry`] and extract text with +//! [`crate::copy_text`]. + +use alloc::vec::Vec; +use core::cmp::Ordering; +use core::fmt::Debug; +use core::ops::Range; + +use parley::editing::Cursor; +use parley::layout::Affinity; + +/// Policy for text boundaries inserted between adjacent selections from different surfaces +/// when serializing with [`crate::copy_text`]. +/// +/// Use this when a single, uniform separator makes sense for your selection (e.g. with +/// [`crate::flow::TextFlow::from_vertical_stack`]). For per-block separators, build your +/// [`crate::flow::TextFlow`] with per-item `join` policies. +/// +/// Guidance on choosing a policy: +/// - `Space` for inline flows (e.g., multiple labels in a row) where spaces are expected. +/// - `Newline` for block/paragraph boundaries so pasted text preserves line breaks. +/// - `None` if your selected ranges already include desired spacing or punctuation. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum BoundaryPolicy { + /// No separator between surfaces. + None, + /// Insert a single ASCII space between surfaces. + Space, + /// Insert a single newline (U+000A) between surfaces. + Newline, +} + +/// A caret positioned on a particular surface. +/// +/// Obtained from [`crate::hit_test`] or navigation utilities. Store it as the active caret +/// in a [`SelectionSet`] (e.g., via [`SelectionSet::collapsed`]) and update it as the user moves +/// the cursor. The `h_pos` field preserves horizontal position during vertical movement. +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct Caret { + /// Surface identifier. + pub surface: Id, + /// Local cursor within the surface’s layout. + pub cursor: Cursor, + /// Sticky horizontal position for vertical movement. + pub h_pos: Option, +} + +/// One selected range on a single surface. +/// +/// Construct segments when you already know the byte range you want selected relative to a +/// specific surface’s text. For contiguous stores, see +/// [`TextBlock::text_slice`](crate::TextBlock::text_slice); for non‑contiguous +/// stores, see [`TextBlock::read_text`](crate::TextBlock::read_text). +/// Add segments to a [`SelectionSet`] with [`SelectionSet::add_segment`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SelectionSegment { + /// Surface identifier this segment applies to. + pub surface: Id, + /// Local byte range in the surface’s source text. + pub range: Range, + /// Affinity on the anchor end. + pub anchor_affinity: Affinity, + /// Affinity on the focus end. + pub focus_affinity: Affinity, +} + +impl SelectionSegment { + /// Create a new segment. + pub fn new(surface: Id, range: Range) -> Self { + Self { + surface, + range, + anchor_affinity: Affinity::Downstream, + focus_affinity: Affinity::Upstream, + } + } +} + +/// An ordered, normalized set of selection segments with a single active caret. +/// +/// Use [`SelectionSet::collapsed`] to start from a caret, then add segments via +/// [`SelectionSet::add_segment`]. Pass the set to rendering/extraction helpers like +/// [`crate::selection_geometry`] and [`crate::copy_text`]. +#[derive(Clone, Debug, PartialEq)] +pub struct SelectionSet { + /// Sorted, non-overlapping segments. + pub segments: Vec>, + /// The active caret for navigation and editing. + pub active: Option>, +} + +impl Default for SelectionSet { + fn default() -> Self { + Self { + segments: Vec::new(), + active: None, + } + } +} + +impl SelectionSet { + /// Create a set from a single caret (collapsed selection). + pub fn collapsed(caret: Caret) -> Self { + Self { + segments: Vec::new(), + active: Some(caret), + } + } + + /// Add a segment and normalize the set (sort and merge overlaps in the same surface). + pub fn add_segment(&mut self, mut seg: SelectionSegment) { + if seg.range.start > seg.range.end { + core::mem::swap(&mut seg.range.start, &mut seg.range.end); + } + self.segments.push(seg); + self.normalize(); + } + + /// Sort segments by (surface, start) and coalesce overlaps/adjacencies within the same surface. + pub fn normalize(&mut self) { + self.segments + .sort_by(|a, b| match a.surface.cmp(&b.surface) { + Ordering::Equal => a.range.start.cmp(&b.range.start), + other => other, + }); + let mut out: Vec> = Vec::with_capacity(self.segments.len()); + for seg in self.segments.drain(..) { + if let Some(last) = out.last_mut() { + if last.surface == seg.surface && last.range.end >= seg.range.start { + // merge + last.range.end = last.range.end.max(seg.range.end); + // keep existing affinities from the earlier segment + continue; + } + } + out.push(seg); + } + self.segments = out; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_merges_overlaps_in_same_surface() { + let mut set: SelectionSet = SelectionSet::default(); + set.add_segment(SelectionSegment::new(7, 0..5)); + set.add_segment(SelectionSegment::new(7, 3..10)); + assert_eq!(set.segments.len(), 1); + assert_eq!(set.segments[0].surface, 7); + assert_eq!(set.segments[0].range.start, 0); + assert_eq!(set.segments[0].range.end, 10); + } + + #[test] + fn normalize_orders_by_surface_then_start_and_merges_adjacencies() { + let mut set: SelectionSet = SelectionSet::default(); + set.add_segment(SelectionSegment::new(2, 5..8)); + set.add_segment(SelectionSegment::new(1, 1..2)); + set.add_segment(SelectionSegment::new(1, 0..1)); + assert_eq!(set.segments.len(), 2); + assert_eq!(set.segments[0].surface, 1); + assert_eq!(set.segments[0].range.start, 0); + assert_eq!(set.segments[0].range.end, 2); + assert_eq!(set.segments[1].surface, 2); + assert_eq!(set.segments[1].range.start, 5); + assert_eq!(set.segments[1].range.end, 8); + } +} diff --git a/parley_flow/src/navigation.rs b/parley_flow/src/navigation.rs new file mode 100644 index 00000000..214f10f1 --- /dev/null +++ b/parley_flow/src/navigation.rs @@ -0,0 +1,169 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Minimal navigation scaffold: move left/right across blocks using a flow. + +use parley::editing::Cursor; +use parley::layout::Affinity; +use parley::style::Brush; + +use crate::{SelectionSet, TextBlock, flow::TextFlow}; + +fn find_block_index_by_id>(blocks: &[S], id: S::Id) -> Option { + blocks.iter().position(|s| s.id() == id) +} + +/// Move the active caret one cluster to the left, crossing blocks by flow order. +pub fn move_left>( + flow: &TextFlow, + blocks: &[S], + set: &mut SelectionSet, +) { + let Some(mut caret) = set.active else { return }; + let Some(ix) = find_block_index_by_id::(blocks, caret.surface) else { + return; + }; + let layout = blocks[ix].layout(); + let prev = caret.cursor; + let next = prev.previous_visual(layout); + if next != prev { + caret.cursor = next; + set.active = Some(caret); + return; + } + let Some(prev_id) = flow.prev_id(caret.surface) else { + return; + }; + let Some(prev_ix) = find_block_index_by_id::(blocks, prev_id) else { + return; + }; + let block = &blocks[prev_ix]; + let end = Cursor::from_byte_index(block.layout(), usize::MAX, Affinity::Upstream); + caret.surface = block.id(); + caret.cursor = end; + set.active = Some(caret); +} + +/// Move the active caret one cluster to the right, crossing blocks by flow order. +pub fn move_right>( + flow: &TextFlow, + blocks: &[S], + set: &mut SelectionSet, +) { + let Some(mut caret) = set.active else { return }; + let Some(ix) = find_block_index_by_id::(blocks, caret.surface) else { + return; + }; + let layout = blocks[ix].layout(); + let prev = caret.cursor; + let next = prev.next_visual(layout); + if next != prev { + caret.cursor = next; + set.active = Some(caret); + return; + } + let Some(next_id) = flow.next_id(caret.surface) else { + return; + }; + let Some(next_ix) = find_block_index_by_id::(blocks, next_id) else { + return; + }; + let block = &blocks[next_ix]; + let start = Cursor::from_byte_index(block.layout(), 0, Affinity::Downstream); + caret.surface = block.id(); + caret.cursor = start; + set.active = Some(caret); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Caret, LayoutBlock, SelectionSet}; + use parley::editing::Cursor; + use parley::{Alignment, AlignmentOptions, FontContext, Layout, LayoutContext, StyleProperty}; + + fn build_layout( + font_cx: &mut FontContext, + layout_cx: &mut LayoutContext<()>, + text: &str, + ) -> Layout<()> { + let mut builder = layout_cx.ranged_builder(font_cx, text, 1.0, true); + builder.push_default(StyleProperty::FontSize(14.0)); + let mut layout: Layout<()> = builder.build(text); + let width = Some(80.0); + layout.break_all_lines(width); + layout.align(width, Alignment::Start, AlignmentOptions::default()); + layout + } + + fn two_blocks_flow() -> (Vec>, TextFlow) { + let mut font_cx = FontContext::new(); + let mut layout_cx = LayoutContext::new(); + let l1 = build_layout(&mut font_cx, &mut layout_cx, "A"); + let l2 = build_layout(&mut font_cx, &mut layout_cx, "B"); + let l1: &'static Layout<()> = Box::leak(Box::new(l1)); + let l2: &'static Layout<()> = Box::leak(Box::new(l2)); + let blocks = vec![ + LayoutBlock { + id: 1, + layout: l1, + text: "A", + }, + LayoutBlock { + id: 2, + layout: l2, + text: "B", + }, + ]; + let flow = TextFlow::new(vec![ + crate::flow::FlowItem::new( + 1, + parley::BoundingBox::new(0.0, 0.0, 1_000_000.0, l1.height() as f64), + crate::BoundaryPolicy::Space, + ), + crate::flow::FlowItem::new( + 2, + parley::BoundingBox::new( + 0.0, + (l1.height() + 2.0) as f64, + 1_000_000.0, + (l1.height() + 2.0 + l2.height()) as f64, + ), + crate::BoundaryPolicy::Space, + ), + ]); + (blocks, flow) + } + + #[test] + fn move_right_crosses_to_next_block_start() { + let (blocks, flow) = two_blocks_flow(); + let caret = Caret { + surface: 1_u32, + cursor: Cursor::from_byte_index(blocks[0].layout, usize::MAX, Affinity::Upstream), + h_pos: None, + }; + let mut set = SelectionSet::collapsed(caret); + move_right::<(), _>(&flow, &blocks, &mut set); + let active = set.active.expect("active"); + assert_eq!(active.surface, 2); + let start2 = Cursor::from_byte_index(blocks[1].layout, 0, Affinity::Downstream); + assert_eq!(active.cursor, start2); + } + + #[test] + fn move_left_crosses_to_prev_block_end() { + let (blocks, flow) = two_blocks_flow(); + let caret = Caret { + surface: 2_u32, + cursor: Cursor::from_byte_index(blocks[1].layout, 0, Affinity::Downstream), + h_pos: None, + }; + let mut set = SelectionSet::collapsed(caret); + move_left::<(), _>(&flow, &blocks, &mut set); + let active = set.active.expect("active"); + assert_eq!(active.surface, 1); + let end1 = Cursor::from_byte_index(blocks[0].layout, usize::MAX, Affinity::Upstream); + assert_eq!(active.cursor, end1); + } +}