Skip to content

Commit d815674

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 d815674

File tree

11 files changed

+1076
-0
lines changed

11 files changed

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

parley_flow/examples/basic.rs

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

parley_flow/src/block.rs

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

0 commit comments

Comments
 (0)