Skip to content

Commit 000b645

Browse files
Introduce parley_flow
This introduces a small, flow‑first layer on top of Parley that treats a document as an ordered set of text blocks, with explicit container rectangles defining where each block lives. The flow owns geometry and the “join” between neighboring blocks (e.g., space vs newline), while blocks only supply layout and text. On top of this, the crate provides hit‑testing, selection geometry, and cross‑block text extraction, so you can drag through multiple paragraphs/labels and get predictable results. Motivation - Borrow from proven platform models (e.g., TextKit containers): separate shaping/layout from flow/regions, keep APIs small and durable. - Reduce ambiguity and API surface: one explicit flow path for hit‑testing, selection, and copy, instead of guessing based on per‑layout state. - Keep integration simple: a convenience builder stacks blocks vertically when you don’t want to hand author rects; otherwise, supply rectangles you already have in your UI.
1 parent 16d3677 commit 000b645

File tree

11 files changed

+1056
-0
lines changed

11 files changed

+1056
-0
lines changed

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ members = [
77
"parley_bench",
88
"parley_core",
99
"parley_dev",
10+
"parley_flow",
1011
"examples/tiny_skia_render",
1112
"examples/swash_render",
1213
"examples/vello_editor",

parley_flow/Cargo.toml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
[package]
2+
name = "parley_flow"
3+
description = "High-level text surfaces, multi-selection, and document aggregation on top of parley"
4+
keywords = ["text", "layout", "flow"]
5+
categories = ["gui", "graphics"]
6+
edition.workspace = true
7+
rust-version.workspace = true
8+
license.workspace = true
9+
repository.workspace = true
10+
exclude = ["/tests"]
11+
readme = "README.md"
12+
13+
[package.metadata.docs.rs]
14+
all-features = true
15+
16+
[lints]
17+
workspace = true
18+
19+
[features]
20+
default = ["std"]
21+
std = ["parley/std"]
22+
libm = ["parley/libm"]
23+
24+
[dependencies]
25+
parley = { workspace = true }

parley_flow/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
parley_flow (TextBlock + TextFlow)
2+
=====================================
3+
4+
A small, flow-first layer on top of Parley that introduces:
5+
6+
- TextBlock: a minimal trait for blocks of laid-out text (paragraphs, labels)
7+
- LayoutBlock: adapter for a `parley::layout::Layout` + `&str`
8+
- TextFlow: explicit ordered containers (rect + join policy) for deterministic hit-testing,
9+
cross-block navigation, and text concatenation
10+
- Flow-based helpers: `hit_test`, `selection_geometry`, `copy_text`
11+
12+
Status: experimental. Names and APIs may change as this evolves.
13+
14+
See `src/design.rs` for comparisons to TextKit/DirectWrite/Android and long-term goals.

parley_flow/examples/basic.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//! Minimal example showing surfaces, multi-selection geometry, and copy.
2+
3+
use parley::{Alignment, AlignmentOptions, FontContext, Layout, LayoutContext, StyleProperty};
4+
use parley_flow::{
5+
BoundaryPolicy, LayoutBlock, SelectionSegment, SelectionSet, copy_text, flow::TextFlow,
6+
hit_test, selection_geometry,
7+
};
8+
9+
fn build_layout<'a>(
10+
font_cx: &mut FontContext,
11+
layout_cx: &mut LayoutContext<()>,
12+
text: &'a str,
13+
) -> Layout<()> {
14+
let mut builder = layout_cx.ranged_builder(font_cx, text, 1.0, true);
15+
builder.push_default(StyleProperty::FontSize(16.0));
16+
let mut layout: Layout<()> = builder.build(text);
17+
let width = Some(200.0);
18+
layout.break_all_lines(width);
19+
layout.align(width, Alignment::Start, AlignmentOptions::default());
20+
layout
21+
}
22+
23+
fn main() {
24+
// Build two simple paragraph layouts with Parley
25+
let mut font_cx = FontContext::new();
26+
let mut layout_cx = LayoutContext::new();
27+
28+
let text1 = "Hello world";
29+
let text2 = "Second line";
30+
let layout1 = build_layout(&mut font_cx, &mut layout_cx, text1);
31+
let layout2 = build_layout(&mut font_cx, &mut layout_cx, text2);
32+
33+
// Wrap them as surfaces with y-offsets stacked vertically
34+
let surfaces = vec![
35+
LayoutBlock {
36+
id: 0u32,
37+
layout: &layout1,
38+
text: text1,
39+
},
40+
LayoutBlock {
41+
id: 1u32,
42+
layout: &layout2,
43+
text: text2,
44+
},
45+
];
46+
let flow = TextFlow::<u32>::from_vertical_stack::<(), _>(&surfaces, BoundaryPolicy::Newline);
47+
48+
// Hit-test near the start of the first paragraph to get a caret
49+
let caret = hit_test::<(), _>(&flow, &surfaces, 2.0, 2.0).expect("caret");
50+
println!("Caret: {:?}", caret);
51+
52+
// Build a multi-selection spanning both surfaces
53+
let mut set = SelectionSet::collapsed(caret);
54+
set.add_segment(SelectionSegment::new(0, 0..5)); // "Hello"
55+
set.add_segment(SelectionSegment::new(1, 0..6)); // "Second"
56+
57+
// Compute geometry (global coordinates)
58+
let mut rects = Vec::new();
59+
selection_geometry::<(), _, _>(&flow, &surfaces, &set, |bb, _| rects.push((bb, 0)));
60+
println!("Selection rects (count={}):", rects.len());
61+
for (bb, surface_ix) in &rects {
62+
println!(
63+
" surface={} rect=({},{})->({},{}))",
64+
surface_ix, bb.x0, bb.y0, bb.x1, bb.y1
65+
);
66+
}
67+
68+
// Extract text across surfaces
69+
let copied = copy_text::<(), _>(&flow, &surfaces, &set);
70+
println!("Copied text:\n{}", copied);
71+
}

parley_flow/src/block.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
//! Text blocks and a simple adapter for Parley [`parley::layout::Layout`].
2+
3+
use alloc::string::String;
4+
use core::ops::Range;
5+
6+
use parley::layout::Layout;
7+
use parley::style::Brush;
8+
9+
/// A uniform facade for anything that behaves like a text layout block.
10+
///
11+
/// Implementers must provide:
12+
/// - A stable identifier (`id`).
13+
/// - A layout reference for geometry and hit-testing.
14+
/// - Text access for extraction via [`TextBlock::text_slice`] and/or
15+
/// [`TextBlock::read_text`].
16+
///
17+
/// Geometry and ordering are defined by `FlowItem` rectangles in a `TextFlow`; this
18+
/// trait does not carry positional information.
19+
pub trait TextBlock<B: Brush> {
20+
/// Identifier type used by this surface set.
21+
type Id: Copy + Ord + Eq + core::fmt::Debug;
22+
23+
/// Stable identifier for the surface.
24+
fn id(&self) -> Self::Id;
25+
26+
/// The underlying Parley layout for this surface.
27+
///
28+
/// This is typically a [`parley::layout::Layout`] built by your code.
29+
fn layout(&self) -> &Layout<B>;
30+
31+
/// Return a borrowed text slice for a local byte `range`, if valid and contiguous.
32+
///
33+
/// This is the fast path used when the underlying storage is contiguous. Implementers that
34+
/// do not have contiguous storage (e.g., ropes) can return `None` and instead implement
35+
/// [`TextBlock::read_text`]. The `range` must be on UTF‑8 character boundaries.
36+
fn text_slice(&self, _range: Range<usize>) -> Option<&str> {
37+
None
38+
}
39+
40+
/// Append the text in the local byte `range` into `out` and return `true` if successful.
41+
///
42+
/// This is the fallback used by helpers like [`crate::copy_text`] to support non‑contiguous
43+
/// storage. The default implementation tries [`TextBlock::text_slice`] and, if present,
44+
/// pushes it into `out`.
45+
fn read_text(&self, range: Range<usize>, out: &mut String) -> bool {
46+
if let Some(s) = self.text_slice(range) {
47+
out.push_str(s);
48+
true
49+
} else {
50+
false
51+
}
52+
}
53+
}
54+
55+
/// A simple adapter turning a Parley [`parley::layout::Layout`] and source text into a
56+
/// [`TextBlock`].
57+
///
58+
/// Construct this when you already have a `Layout` and the `&str` it was built from, then
59+
/// pass it to helpers like [`crate::hit_test`], [`crate::selection_geometry`], and
60+
/// [`crate::copy_text`].
61+
#[derive(Copy, Clone)]
62+
pub struct LayoutBlock<'a, B: Brush, Id> {
63+
/// Stable identifier for the surface.
64+
pub id: Id,
65+
/// The layout to expose.
66+
pub layout: &'a Layout<B>,
67+
/// The source text used to build `layout`.
68+
pub text: &'a str,
69+
}
70+
71+
impl<'a, B: Brush, Id: Copy + Ord + Eq + core::fmt::Debug> TextBlock<B> for LayoutBlock<'a, B, Id> {
72+
type Id = Id;
73+
74+
fn id(&self) -> Self::Id {
75+
self.id
76+
}
77+
78+
fn layout(&self) -> &Layout<B> {
79+
self.layout
80+
}
81+
82+
fn text_slice(&self, range: Range<usize>) -> Option<&str> {
83+
self.text.get(range)
84+
}
85+
86+
fn read_text(&self, range: Range<usize>, out: &mut String) -> bool {
87+
if let Some(slice) = self.text.get(range) {
88+
out.push_str(slice);
89+
true
90+
} else {
91+
false
92+
}
93+
}
94+
}
95+
96+
impl<'a, B: Brush, Id: Copy + Ord + Eq + core::fmt::Debug> core::fmt::Debug
97+
for LayoutBlock<'a, B, Id>
98+
{
99+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
100+
f.debug_struct("LayoutBlock")
101+
.field("id", &self.id)
102+
.field("text_len", &self.text.len())
103+
.finish_non_exhaustive()
104+
}
105+
}

parley_flow/src/design.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
//! Design notes, comparisons, and long-term considerations.
2+
//!
3+
//! Parley’s core (`parley`) is intentionally paragraph-focused and pure: it owns shaping,
4+
//! line breaking, bidi, and alignment. This crate builds a higher-level layer for composing
5+
//! many paragraphs/surfaces and for modeling multi-selection/navigation.
6+
//!
7+
//! ## Inspiration and Comparisons
8+
//!
9+
//! ### Apple TextKit 1/2
10+
//!
11+
//! - Layering: `NSTextStorage` (attributed storage) → `NSLayoutManager`/`NSTextLayoutManager`
12+
//! (shaping/line breaking) → `NSTextContainer` (geometry/regions). Storage can flow through
13+
//! multiple containers for columns/pages.
14+
//! - Selection/navigation: First-class objects (`NSTextSelection`, `NSTextSelectionNavigation`)
15+
//! with anchor/focus, affinity, and granularity (character/word/line/paragraph).
16+
//! - Attachments: `NSTextAttachment` uses object-replacement semantics, which maps to Parley
17+
//! inline boxes and helps serialization (U+FFFC placeholder).
18+
//! - Takeaways for us:
19+
//! - Preserve a strict separation: storage vs. layout vs. region.
20+
//! - Model selection/navigation as reusable services that operate over regions/surfaces.
21+
//! - Treat inline objects as real selection units and serialization boundaries.
22+
//!
23+
//! ### Windows DirectWrite + TSF
24+
//!
25+
//! - DirectWrite: paragraph-centric layouts (`IDWriteTextLayout`) composed by host toolkits
26+
//! into documents. Justification, trimming, and typographic knobs exposed as explicit options.
27+
//! - TSF (Text Services Framework): composition (IME) modeled as ranges; selection kept in sync;
28+
//! UTF‑16 code unit indexing as lingua franca.
29+
//! - Takeaways:
30+
//! - Keep layout per-paragraph; composition/selection are ranges in storage.
31+
//! - Provide explicit UTF‑8↔UTF‑16 conversions at the edges for platform interoperability.
32+
//! - Don’t bake editing into layout; keep layout pure and reusable.
33+
//!
34+
//! ### Android (Spannable, MovementMethod, InputConnection)
35+
//!
36+
//! - Ranges: Spans with inclusive/exclusive flags survive edits; replacement spans for attachments.
37+
//! - Navigation: MovementMethod separates navigation from widgets, enabling reuse across views.
38+
//! - IME: InputConnection is an explicit bridge for composition, commit, and selection updates.
39+
//! - Precompute: Precomputed/Measured text validates doing shaping/measurement off the UI thread
40+
//! and reusing results (like `LayoutContext`).
41+
//! - Cautions: span proliferation and watchers can become hot paths; cross-widget selection is
42+
//! not first-class.
43+
//! - Takeaways:
44+
//! - Favor compact range tables over callback-heavy span objects.
45+
//! - Keep navigation and IME bridges separate, explicit, and host-driven.
46+
//! - Make async/precomputed layout a supported pattern via caches + generations.
47+
//!
48+
//! ### WPF/Web (brief)
49+
//!
50+
//! - WPF: `TextContainer`/`TextPointer`/`TextSelection` abstractions, flow across regions.
51+
//! - Web: `Range` across nodes, selection APIs that treat node boundaries as hard breaks; rich
52+
//! serialization policies matter.
53+
//! - Takeaways:
54+
//! - Encapsulate positions as abstract pointers, not raw indices.
55+
//! - Provide serialization policies (logical/visual order, boundary separators).
56+
//!
57+
//! ## Key Choices for a 10+ Year Horizon
58+
//!
59+
//! - Keep editor/navigation policies out of `Layout`; implement them in this crate.
60+
//! - Favor explicit types for locations/ranges across surfaces, with conversions to platform units.
61+
//! - Treat surface boundaries as hard boundaries for movement/word/line granularity.
62+
//! - Provide read-only aggregation (copy/search/AX) across surfaces by default; editing across
63+
//! multiple surfaces is opt-in and typically limited to a single active caret.
64+
//!
65+
//! ## Future Work
66+
//!
67+
//! ### Surface Flow and Ordering
68+
//!
69+
//! Parley surfaces are intentionally minimal. Explicit flow is modeled by `flow::TextFlow`:
70+
//! - Each `FlowItem` encodes a `block_id`, a `rect` (for hit-testing/geometry), and a `join`
71+
//! policy for serialization.
72+
//! - Order in the `TextFlow` defines cross-block navigation and concatenation semantics.
73+
//! - This mirrors TextKit’s ordered container array.
74+
//!
75+
//! Guidance:
76+
//! - Provide non-overlapping `rect`s in the flow for deterministic hit-testing.
77+
//! - Use large widths/heights when you don’t care about precise bounds; the flow determines order.
78+
//! - For multi-column/page or virtualized layouts, build the `TextFlow` with the intended reading
79+
//! order and rects for each visible block.
80+
//! - Concatenation: use a uniform `join` via [`crate::flow::TextFlow::from_vertical_stack`] or assign
81+
//! per-item `join` policies (e.g., `Space` for inline, `Newline` for block boundaries).
82+
//!
83+
//! ### 1) TextLocation and TextRange (positions across surfaces)
84+
//!
85+
//! - Purpose: decouple pointer/range representations from raw indices; enable safe conversions to
86+
//! platform units and stable identity across edits.
87+
//! - Shape:
88+
//! - `TextLocation { surface_id, utf8: usize }` with helpers: `to_utf16()`, `from_utf16()`.
89+
//! - `TextRange { start: TextLocation, end: TextLocation }`, normalized with methods for
90+
//! granularity expansion (to cluster/word/line/paragraph) using the surface’s layout.
91+
//! - Invariants:
92+
//! - Always at character boundaries in UTF‑8; conversion to UTF‑16 is lossy only in units, not
93+
//! in meaning.
94+
//! - Stable `surface_id` and monotonic ordering within a surface.
95+
//! - Interop:
96+
//! - Map to TSF/AppKit APIs that require UTF‑16 indices; provide zero‑allocation conversions.
97+
//! - Serialize as absolute (surface_id, byte_offset) to avoid ambiguity when text changes.
98+
//! - Integration:
99+
//! - Backed by the same hit-testing code as current `Cursor`/`Selection`.
100+
//! - Acts as the wire type for accessibility and IME bridges.
101+
//!
102+
//! ### 2) SelectionNavigation (surface‑crossing caret movement)
103+
//!
104+
//! - Purpose: unify navigation semantics (move/extend by cluster/word/line/paragraph) across
105+
//! multiple surfaces in visual order, mirroring Apple’s `NSTextSelectionNavigation`.
106+
//! - Current scaffold:
107+
//! - Implemented in `crate::selection_navigation` with `move_left`/`move_right` that cross
108+
//! surface boundaries by jumping to the end/start of adjacent surfaces.
109+
//! - Inside a surface, movement delegates to Parley’s `Cursor` logic.
110+
//! - Tests cover crossing from the end of one surface to the start of the next, and vice versa.
111+
//! - Next steps:
112+
//! - Vertical movement preserving `h_pos`: `move_line_up`/`move_line_down`.
113+
//! - Word/paragraph granularity: `move_word_left/right`, `hard_line_start/end`.
114+
//! - Extend variants (Shift-modify): introduce an anchor caret in `SelectionSet` or pass a
115+
//! transient anchor so movement can grow/shrink the nearest segment or add cross-surface
116+
//! segments.
117+
//! - Surface ordering predicates: allow clients to supply ordering beyond y-offset (e.g., columns).
118+
//! - Semantics to retain:
119+
//! - Affinity and bidi: respect `Cursor` affinity at line ends; treat surface edges as hard
120+
//! boundaries; never span a cluster across surfaces.
121+
//! - Vertical movement: maintain a sticky `h_pos` in global coordinates; when moving across
122+
//! surfaces, compute target y in the next surface via its `y_offset` and line metrics.
123+
//! - Word/line boundaries: use per-surface rules; when crossing a surface, land at the first/last
124+
//! selectable boundary inside the target surface.
125+
//! - Testing:
126+
//! - Golden tests for bidi line ends, RTL/LTR boundaries, mixed metrics; property tests for
127+
//! inverse moves where appropriate.
128+
//!
129+
//! ### 3) Document Storage and Incremental Relayout
130+
//!
131+
//! - Purpose: provide a concrete “document” built from paragraphs with efficient editing,
132+
//! invalidation, and relayout, while still exposing each paragraph as a `TextBlock`.
133+
//! - Storage:
134+
//! - Use a rope or gap buffer for large texts; maintain paragraph boundaries as a side table of
135+
//! byte ranges with stable paragraph IDs.
136+
//! - Inclusive/exclusive range flags for styles, composition, and annotations that survive edits.
137+
//! - Invalidation:
138+
//! - When editing, detect impacted paragraphs (splits/merges on newlines) and only rebuild
139+
//! affected layouts; reuse shaped results when attributes allow.
140+
//! - Employ generations to coordinate caches (fonts, shaping, line breaking) and async rebuilds.
141+
//! - Layout:
142+
//! - Build each paragraph’s `Layout` via `LayoutContext`; cache glyph/cluster data.
143+
//! - Support async layout: produce placeholder metrics/geometry, swap in final results when ready.
144+
//! - Regions:
145+
//! - Flow paragraphs into regions (columns/pages) by assigning `y_offset`s; each paragraph is a
146+
//! `TextBlock` with its own layout and text slice.
147+
//! - Attachments:
148+
//! - Represent as inline boxes using object‑replacement semantics (U+FFFC) for selection and
149+
//! serialization; geometry carried by the inline box.
150+
//!
151+
//! This module contains only documentation.

0 commit comments

Comments
 (0)