diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 269db567..54f8c158 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,15 @@ env: RUST_MIN_VER: "1.88" # List of packages that will be checked with the minimum supported Rust version. # This should be limited to packages that are intended for publishing. - RUST_MIN_VER_PKGS: "-p parley -p parley_core -p parley_data -p parlance -p fontique -p attributed_text" + RUST_MIN_VER_PKGS: >- + -p parley + -p parley_core + -p parley_data + -p parlance + -p fontique + -p attributed_text + -p styled_text + -p styled_text_parley # List of features that depend on the standard library and will be excluded from no_std checks. FEATURES_DEPENDING_ON_STD: "std,default,png,system,vello_hybrid" # List of packages that can not target Wasm. @@ -106,6 +114,13 @@ jobs: - name: Run cargo rdme (parlance) run: cargo rdme --check --heading-base-level=0 --workspace-project=parlance + - name: Run cargo rdme (styled_text) + run: cargo rdme --check --heading-base-level=0 --workspace-project=styled_text + + - name: Run cargo rdme (styled_text_parley) + run: cargo rdme --check --heading-base-level=0 --workspace-project=styled_text_parley + + clippy-stable: name: cargo clippy runs-on: ${{ matrix.os }} diff --git a/Cargo.lock b/Cargo.lock index 15bce3ff..ed026ace 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3762,6 +3762,21 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "styled_text" +version = "0.1.0" +dependencies = [ + "attributed_text", +] + +[[package]] +name = "styled_text_parley" +version = "0.1.0" +dependencies = [ + "parley", + "styled_text", +] + [[package]] name = "supports-color" version = "3.0.2" @@ -4256,6 +4271,19 @@ dependencies = [ "vello_cpu", ] +[[package]] +name = "vello_cpu_render_styled_text" +version = "0.1.0" +dependencies = [ + "glifo", + "parley", + "parley_examples_common", + "peniko", + "styled_text", + "styled_text_parley", + "vello_cpu", +] + [[package]] name = "vello_editor" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 1723d492..7694fbd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,10 +11,13 @@ members = [ "parley_data_gen", "parley_dev", "parley_tests", + "styled_text", + "styled_text_parley", "examples/common", "examples/swash_render", "examples/tiny_skia_render", "examples/vello_cpu_render", + "examples/vello_cpu_render_styled_text", "examples/vello_editor", "examples/vello_hybrid_render", "xtask", diff --git a/examples/vello_cpu_render_styled_text/Cargo.toml b/examples/vello_cpu_render_styled_text/Cargo.toml new file mode 100644 index 00000000..0322a2db --- /dev/null +++ b/examples/vello_cpu_render_styled_text/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "vello_cpu_render_styled_text" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +glifo = { workspace = true, default-features = false, features = ["std", "png", "vello_cpu"] } +parley = { workspace = true, default-features = true } +parley_examples_common = { path = "../common" } +peniko = { workspace = true } +styled_text = { path = "../../styled_text" } +styled_text_parley = { path = "../../styled_text_parley" } +vello_cpu = { workspace = true, default-features = false, features = ["std", "png"] } + +[features] +default = ["png"] +png = [] + +[lints] +workspace = true diff --git a/examples/vello_cpu_render_styled_text/src/main.rs b/examples/vello_cpu_render_styled_text/src/main.rs new file mode 100644 index 00000000..de7fd964 --- /dev/null +++ b/examples/vello_cpu_render_styled_text/src/main.rs @@ -0,0 +1,400 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Renders text styled with `styled_text`, lowered through `styled_text_parley`, +//! and painted with Vello CPU. + +#![expect(clippy::cast_possible_truncation, reason = "example image sizes")] + +use std::path::Path; +use std::sync::Arc; + +use glifo::renderers::vello_renderer::replay_atlas_commands; +use glifo::{ + AtlasConfig, CpuGlyphCaches, GlyphCache, GlyphCacheConfig, GlyphRunBuilder, ImageCache, + PendingClearRect, +}; +use parley::{ + Alignment, AlignmentOptions, FontContext, FontWeight, GenericFamily, GlyphRun, Layout, + LayoutContext, LineHeight, PositionedLayoutItem, +}; +use parley_examples_common::{ColorBrush, output_dir}; +use peniko::Color; +use styled_text_parley::{ + ParleyLayoutStyle, ParleyPaintStyle, ParleyStyleChange, ParleyStyleRunWorkspace, + ParleyStyledTextBuilder, build_layout_from_parley_styled_text, +}; +use vello_cpu::{ + Pixmap, RenderContext, + kurbo::{Affine, Rect, Vec2}, +}; + +const TEXT: &str = concat!( + "StyledText + Parley\n", + "This example interns local style payloads, stores compact IDs on spans,\n", + "resolves overlapping ranges with reusable scratch space, and feeds Parley style runs.\n", + "\n", + "BIG, tiny, underline, strike, and paint-only color.\n", + "Some bidirectional text: English العربية.\n", +); + +fn main() { + let display_scale = 1.0; + let quantize = true; + let max_advance = Some(560.0 * display_scale); + let padding = 24; + + let base_layout = ParleyLayoutStyle { + font_family: GenericFamily::SystemUi.into(), + font_size: 18.0, + line_height: LineHeight::FontSizeRelative(1.35), + ..ParleyLayoutStyle::default() + }; + let black = ColorBrush { + color: Color::BLACK, + }; + let blue = ColorBrush { + color: Color::from_rgb8(39, 92, 180), + }; + let red = ColorBrush { + color: Color::from_rgb8(190, 53, 48), + }; + let green = ColorBrush { + color: Color::from_rgb8(36, 130, 83), + }; + let purple = ColorBrush { + color: Color::from_rgb8(112, 69, 166), + }; + + let mut styled = ParleyStyledTextBuilder::new(base_layout, ParleyPaintStyle::new(black)); + styled.reserve(TEXT.len(), 8); + styled.push_with( + "StyledText + Parley\n", + ParleyStyleChange::default() + .font_size(36.0) + .font_weight(FontWeight::BOLD) + .underline(true) + .brush(blue), + ); + styled.push("This example interns local style payloads, stores compact IDs on spans,\n"); + styled.push( + "resolves overlapping ranges with reusable scratch space, and feeds Parley style runs.\n", + ); + styled.push("\n"); + styled.push_with( + "BIG", + ParleyStyleChange::default() + .font_size(32.0) + .font_weight(FontWeight::BOLD) + .letter_spacing(1.0) + .brush(red), + ); + styled.push(", "); + let tiny = styled.push_with("tiny", ParleyStyleChange::default().font_size(12.0)); + styled.apply(tiny, ParleyStyleChange::default().brush(purple)); + styled.push(", "); + styled.push_with( + "underline", + ParleyStyleChange::default().underline(true).brush(green), + ); + styled.push(", "); + styled.push_with( + "strike", + ParleyStyleChange::default() + .strikethrough(true) + .brush(purple), + ); + styled.push(", and "); + styled.push_with("paint-only color", ParleyStyleChange::default().brush(red)); + styled.push(".\n"); + styled.push("Some bidirectional text: English العربية.\n"); + let styled = styled.finish(); + + let mut font_cx = FontContext::new(); + let mut layout_cx = LayoutContext::new(); + let mut workspace = ParleyStyleRunWorkspace::new(); + + let mut layout = build_layout_from_parley_styled_text( + &mut layout_cx, + &mut font_cx, + &styled, + &mut workspace, + display_scale, + quantize, + ) + .expect("example layout should build"); + + layout.break_all_lines(max_advance); + layout.align(Alignment::Start, AlignmentOptions::default()); + + let width = layout.width().ceil() as u16 + padding * 2; + let height = layout.height().ceil() as u16 + padding * 2; + let output_path = + output_dir(env!("CARGO_MANIFEST_DIR")).join("vello_cpu_render_styled_text.png"); + + render_layout(&layout, width, height, padding, &output_path); + println!("wrote {}", output_path.display()); +} + +fn render_layout( + layout: &Layout, + width: u16, + height: u16, + padding: u16, + output_path: &Path, +) { + let (mut renderer, mut glyph_renderer, mut glyph_caches, mut image_cache) = + prepare_rendering(width, height); + + reset_renderer( + &mut renderer, + &mut glyph_renderer, + width, + height, + padding, + Color::from_rgb8(250, 250, 252), + ); + + for line in layout.lines() { + for item in line.items() { + match item { + PositionedLayoutItem::GlyphRun(glyph_run) => { + render_glyph_run( + &mut renderer, + &mut glyph_caches, + &mut image_cache, + &glyph_run, + ); + } + PositionedLayoutItem::InlineBox(inline_box) => { + renderer.set_paint(Color::BLACK); + let (x0, y0) = (inline_box.x as f64, inline_box.y as f64); + let (x1, y1) = (x0 + inline_box.width as f64, y0 + inline_box.height as f64); + renderer.fill_rect(&Rect::new(x0, y0, x1, y1)); + } + } + } + } + + let pixmap = render( + &mut renderer, + &mut glyph_caches, + &mut image_cache, + width, + height, + &mut glyph_renderer, + ); + save_output(pixmap, output_path); +} + +fn prepare_rendering( + width: u16, + height: u16, +) -> (RenderContext, RenderContext, CpuGlyphCaches, ImageCache) { + let atlas_size = (256, 256); + let renderer = RenderContext::new(width, height); + let image_cache = ImageCache::new_with_config(AtlasConfig { + initial_atlas_count: 1, + max_atlases: 1, + atlas_size: (u32::from(atlas_size.0), u32::from(atlas_size.1)), + auto_grow: false, + ..Default::default() + }); + let glyph_renderer = RenderContext::new(atlas_size.0, atlas_size.1); + let glyph_caches = CpuGlyphCaches::with_config( + 256, + 256, + GlyphCacheConfig { + max_entry_age: 2, + eviction_frequency: 2, + max_cached_font_size: 128.0, + }, + ); + (renderer, glyph_renderer, glyph_caches, image_cache) +} + +fn reset_renderer( + renderer: &mut RenderContext, + glyph_renderer: &mut RenderContext, + width: u16, + height: u16, + padding: u16, + background_color: Color, +) { + renderer.reset(); + glyph_renderer.reset(); + renderer.set_paint(background_color); + renderer.fill_rect(&Rect::new(0.0, 0.0, width as f64, height as f64)); + renderer.set_transform(Affine::translate(Vec2::new( + f64::from(padding), + f64::from(padding), + ))); +} + +fn render_glyph_run( + renderer: &mut RenderContext, + glyph_caches: &mut CpuGlyphCaches, + image_cache: &mut ImageCache, + glyph_run: &GlyphRun<'_, ColorBrush>, +) { + let run = glyph_run.run(); + let mut run_renderer = GlyphRunBuilder::new(run.font().clone(), *renderer.transform()) + .font_size(run.font_size()) + .hint(true) + .normalized_coords(run.normalized_coords()) + .atlas_cache(true) + .build( + glyph_run.positioned_glyphs().map(|glyph| glifo::Glyph { + id: glyph.id, + x: glyph.x, + y: glyph.y, + }), + glyph_caches, + image_cache, + ); + + renderer.set_paint(glyph_run.style().brush.color); + run_renderer.fill_glyphs(renderer); + + let style = glyph_run.style(); + if let Some(decoration) = &style.underline { + let offset = decoration.offset.unwrap_or(run.metrics().underline_offset); + let size = decoration.size.unwrap_or(run.metrics().underline_size); + renderer.set_paint(decoration.brush.color); + let x = glyph_run.offset(); + let x1 = x + glyph_run.advance(); + run_renderer.render_decoration(x..=x1, glyph_run.baseline(), offset, size, 1.0, renderer); + } + if let Some(decoration) = &style.strikethrough { + let offset = decoration + .offset + .unwrap_or(run.metrics().strikethrough_offset); + let size = decoration.size.unwrap_or(run.metrics().strikethrough_size); + render_strikethrough(renderer, &decoration.brush, glyph_run, offset, size); + } +} + +fn render_strikethrough( + renderer: &mut RenderContext, + brush: &ColorBrush, + glyph_run: &GlyphRun<'_, ColorBrush>, + offset: f32, + size: f32, +) { + renderer.set_paint(brush.color); + let y = glyph_run.baseline() - offset; + let x = glyph_run.offset(); + let x1 = x + glyph_run.advance(); + let y1 = y + size; + renderer.fill_rect(&Rect::new(x as f64, y as f64, x1 as f64, y1 as f64)); +} + +fn render( + renderer: &mut RenderContext, + glyph_caches: &mut CpuGlyphCaches, + image_cache: &mut ImageCache, + width: u16, + height: u16, + glyph_renderer: &mut RenderContext, +) -> Pixmap { + glyph_caches + .glyph_atlas + .replay_pending_atlas_commands_with_pixmaps(|recorder, pixmaps| { + glyph_renderer.reset(); + replay_atlas_commands(&mut recorder.commands, glyph_renderer); + glyph_renderer.flush(); + if let Some(atlas_pixmap) = pixmaps + .get_mut(recorder.page_index as usize) + .and_then(Arc::get_mut) + { + glyph_renderer.composite_to_pixmap_at_offset(atlas_pixmap, 0, 0); + } + }); + + let uploads: Vec<_> = glyph_caches.glyph_atlas.drain_pending_uploads().collect(); + for upload in uploads { + let page_index = upload.atlas_slot.page_index as usize; + let Some(atlas_pixmap) = glyph_caches.glyph_atlas.page_pixmap_mut(page_index) else { + continue; + }; + + copy_pixmap_to_atlas( + &upload.pixmap, + atlas_pixmap, + upload.atlas_slot.x, + upload.atlas_slot.y, + upload.atlas_slot.width, + upload.atlas_slot.height, + ); + } + + let page_count = glyph_caches.glyph_atlas.page_count(); + for page_index in 0..page_count { + if let Some(page_pixmap) = glyph_caches.glyph_atlas.page_pixmap(page_index) { + renderer.register_image(page_pixmap.clone()); + } + } + + let mut pixmap = Pixmap::new(width, height); + renderer.render_to_pixmap(&mut pixmap); + renderer.clear_images(); + glyph_caches.maintain(image_cache); + + let clear_rects: Vec<_> = glyph_caches + .glyph_atlas + .drain_pending_clear_rects() + .collect(); + for rect in clear_rects { + if let Some(atlas_pixmap) = glyph_caches + .glyph_atlas + .page_pixmap_mut(rect.page_index as usize) + { + clear_pixmap_region(atlas_pixmap, &rect); + } + } + + pixmap +} + +fn save_output(pixmap: Pixmap, output_path: &Path) { + let png = pixmap.into_png().unwrap(); + std::fs::write(output_path, &png).unwrap(); +} + +fn clear_pixmap_region(dst: &mut Pixmap, rect: &PendingClearRect) { + let dst_stride = dst.width() as usize; + let dst_data = dst.data_as_u8_slice_mut(); + let clear_width = rect.width as usize; + let clear_height = rect.height as usize; + + for y in 0..clear_height { + let row_start = ((rect.y as usize + y) * dst_stride + rect.x as usize) * 4; + let row_end = row_start + clear_width * 4; + dst_data[row_start..row_end].fill(0); + } +} + +fn copy_pixmap_to_atlas( + src: &Pixmap, + dst: &mut Pixmap, + dst_x: u16, + dst_y: u16, + width: u16, + height: u16, +) { + let copy_width = width as usize; + let copy_height = height as usize; + let src_stride = src.width() as usize; + let dst_stride = dst.width() as usize; + + let src_data = src.data_as_u8_slice(); + let dst_data = dst.data_as_u8_slice_mut(); + + for row in 0..copy_height { + let src_start = row * src_stride * 4; + let src_end = src_start + copy_width * 4; + let dst_start = ((usize::from(dst_y) + row) * dst_stride + usize::from(dst_x)) * 4; + let dst_end = dst_start + copy_width * 4; + dst_data[dst_start..dst_end].copy_from_slice(&src_data[src_start..src_end]); + } +} diff --git a/styled_text/CHANGELOG.md b/styled_text/CHANGELOG.md new file mode 100644 index 00000000..8a428fa8 --- /dev/null +++ b/styled_text/CHANGELOG.md @@ -0,0 +1,19 @@ + + +# Changelog + +No `styled_text` release from this repository has been published yet. + +## [Unreleased] + +This release has an [MSRV] of 1.88. + +[Unreleased]: https://github.com/linebender/parley/commits/main/styled_text + +[MSRV]: README.md#minimum-supported-rust-version-msrv diff --git a/styled_text/Cargo.toml b/styled_text/Cargo.toml new file mode 100644 index 00000000..5b4cef59 --- /dev/null +++ b/styled_text/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "styled_text" +version = "0.1.0" +description = "Compact styled text spans built on attributed_text" +keywords = ["text", "rich text", "style"] +categories = ["graphics", "text"] +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +publish = false + +[package.metadata.docs.rs] +all-features = true +default-target = "x86_64-unknown-linux-gnu" +targets = [] + +[features] +default = ["std"] +std = ["attributed_text/std"] + +[dependencies] +attributed_text = { path = "../attributed_text", default-features = false } + +[lints] +workspace = true diff --git a/styled_text/LICENSE-APACHE b/styled_text/LICENSE-APACHE new file mode 100644 index 00000000..d9a10c0d --- /dev/null +++ b/styled_text/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/styled_text/LICENSE-MIT b/styled_text/LICENSE-MIT new file mode 100644 index 00000000..9cf10627 --- /dev/null +++ b/styled_text/LICENSE-MIT @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/styled_text/README.md b/styled_text/README.md new file mode 100644 index 00000000..cb2d26ed --- /dev/null +++ b/styled_text/README.md @@ -0,0 +1,174 @@ +
+ +# Styled Text + +Compact styled text spans built on attributed_text. + +[![Linebender Zulip, #parley channel](https://img.shields.io/badge/Linebender-%23parley-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/channel/205635-parley) +[![dependency status](https://deps.rs/repo/github/linebender/parley/status.svg)](https://deps.rs/repo/github/linebender/parley) +[![Apache 2.0 or MIT license.](https://img.shields.io/badge/license-Apache--2.0_OR_MIT-blue.svg)](#license) +[![Build status](https://github.com/linebender/parley/workflows/CI/badge.svg)](https://github.com/linebender/parley/actions) +[![Crates.io](https://img.shields.io/crates/v/styled_text.svg)](https://crates.io/crates/styled_text) +[![Docs](https://docs.rs/styled_text/badge.svg)](https://docs.rs/styled_text) + +
+ + + + + + +Styled Text stores text with compact full-style identifiers. +It builds on [`attributed_text`] for range storage and segment resolution, +then adds interned style payloads for callers that need compact, reusable +style data. + +The core idea is that each resolved text segment points at a [`StyleId`]. +The [`StyleSet`] behind that id stores complete style records, with +layout-affecting payloads and paint-only payloads interned separately. +That means a paint-only change can share layout style identity with the +surrounding text, which is the information a shaping or layout cache usually +wants. + +This is deliberately not a document model. +It does not own shaping, font resolution, inline boxes, cascading, or +renderer-specific style semantics. +Callers choose their own layout and paint payload types, then adapt the +resolved segments to Parley or another layout system. + +Unlike an API that sets individual style bits on ranges and leaves a later +stage to interpret those bits, `styled_text` resolves patches into complete +style records before lowering. +That keeps the core crate independent of any toolkit's style vocabulary +while giving downstream code a simple stream of text ranges and full styles. + +## Concepts + +- [`StyledText`] stores the text, the resolved [`StyleId`] spans, and the + shared [`StyleSet`]. +- [`StyleSet`] interns layout payloads, paint payloads, and the joined style + records that point at them. +- [`StylePatch`] is the small trait callers implement to say how a partial + style change updates their own full style types. +- [`StyledTextBuilder`] appends text and applies patches in the order they + were applied to the builder. +- [`StyledSegmentsWorkspace`] is reusable scratch storage for iterating + resolved styled segments without reallocating every time. + +## Building styled text + +```rust +use styled_text::{StylePatch, StyledSegmentsWorkspace, StyledTextBuilder}; + +#[derive(Clone, Debug, PartialEq, Default)] +struct LayoutStyle { + font_size: f32, +} + +#[derive(Clone, Debug, PartialEq, Default)] +struct PaintStyle { + rgba: [u8; 4], +} + +#[derive(Clone, Debug, Default)] +struct TextStyleChange { + font_size: Option, + rgba: Option<[u8; 4]>, +} + +impl StylePatch for TextStyleChange { + fn apply_to(&self, layout: &mut LayoutStyle, paint: &mut PaintStyle) { + if let Some(font_size) = self.font_size { + layout.font_size = font_size; + } + if let Some(rgba) = self.rgba { + paint.rgba = rgba; + } + } +} + +let mut text = StyledTextBuilder::new( + LayoutStyle { font_size: 16.0 }, + PaintStyle { rgba: [0, 0, 0, 255] }, +); +text.push("Hello "); +let styled_range = text.push_with( + "styled", + TextStyleChange { + font_size: Some(28.0), + ..TextStyleChange::default() + }, +); +text.apply( + styled_range, + TextStyleChange { + rgba: Some([220, 40, 40, 255]), + ..TextStyleChange::default() + }, +); +text.push(" text"); +let styled = text.finish(); + +let mut workspace = StyledSegmentsWorkspace::new(); +for segment in workspace.segments(&styled) { + let style = styled.style_set().segment_style(segment.style()); + // Feed segment.range() and style into layout or painting code. +} +``` + +## Features + +- `std` (enabled by default): Enables the `std` feature of + [`attributed_text`]. + + + +## Minimum supported Rust Version (MSRV) + +This version of Styled Text has been verified to compile with **Rust 1.88** and later. + +Future versions of Styled Text might increase the Rust version requirement. +It will not be treated as a breaking change and as such can even happen with small patch releases. + +
+Click here if compiling fails. + +As time has passed, some of Styled Text's dependencies could have released versions with a higher Rust requirement. +If you encounter a compilation issue due to a dependency and don't want to upgrade your Rust toolchain, then you could downgrade the dependency. + +```sh +# Use the problematic dependency's name and version +cargo update -p package_name --precise 0.1.1 +``` + +
+ +## Community + +[![Linebender Zulip](https://img.shields.io/badge/Xi%20Zulip-%23parley-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/channel/205635-parley) + +Discussion of Styled Text development happens in the [Linebender Zulip](https://xi.zulipchat.com/), specifically the [#parley channel](https://xi.zulipchat.com/#narrow/channel/205635-parley). +All public content can be read without logging in. + +## License + +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or ) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or ) + +at your option. + +## Contribution + +Contributions are welcome by pull request. The [Rust code of conduct] applies. +Please feel free to add your name to the [AUTHORS] file in any substantive pull request. + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be licensed as above, without any additional terms or conditions. + +[Rust Code of Conduct]: https://www.rust-lang.org/policies/code-of-conduct +[AUTHORS]: ../AUTHORS diff --git a/styled_text/src/lib.rs b/styled_text/src/lib.rs new file mode 100644 index 00000000..c73f4f14 --- /dev/null +++ b/styled_text/src/lib.rs @@ -0,0 +1,132 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Styled Text stores text with compact full-style identifiers. +//! It builds on [`attributed_text`] for range storage and segment resolution, +//! then adds interned style payloads for callers that need compact, reusable +//! style data. +//! +//! The core idea is that each resolved text segment points at a [`StyleId`]. +//! The [`StyleSet`] behind that id stores complete style records, with +//! layout-affecting payloads and paint-only payloads interned separately. +//! That means a paint-only change can share layout style identity with the +//! surrounding text, which is the information a shaping or layout cache usually +//! wants. +//! +//! This is deliberately not a document model. +//! It does not own shaping, font resolution, inline boxes, cascading, or +//! renderer-specific style semantics. +//! Callers choose their own layout and paint payload types, then adapt the +//! resolved segments to Parley or another layout system. +//! +//! Unlike an API that sets individual style bits on ranges and leaves a later +//! stage to interpret those bits, `styled_text` resolves patches into complete +//! style records before lowering. +//! That keeps the core crate independent of any toolkit's style vocabulary +//! while giving downstream code a simple stream of text ranges and full styles. +//! +//! ## Concepts +//! +//! - [`StyledText`] stores the text, the resolved [`StyleId`] spans, and the +//! shared [`StyleSet`]. +//! - [`StyleSet`] interns layout payloads, paint payloads, and the joined style +//! records that point at them. +//! - [`StylePatch`] is the small trait callers implement to say how a partial +//! style change updates their own full style types. +//! - [`StyledTextBuilder`] appends text and applies patches in the order they +//! were applied to the builder. +//! - [`StyledSegmentsWorkspace`] is reusable scratch storage for iterating +//! resolved styled segments without reallocating every time. +//! +//! ## Building styled text +//! +//! ```no_run +//! use styled_text::{StylePatch, StyledSegmentsWorkspace, StyledTextBuilder}; +//! +//! #[derive(Clone, Debug, PartialEq, Default)] +//! struct LayoutStyle { +//! font_size: f32, +//! } +//! +//! #[derive(Clone, Debug, PartialEq, Default)] +//! struct PaintStyle { +//! rgba: [u8; 4], +//! } +//! +//! #[derive(Clone, Debug, Default)] +//! struct TextStyleChange { +//! font_size: Option, +//! rgba: Option<[u8; 4]>, +//! } +//! +//! impl StylePatch for TextStyleChange { +//! fn apply_to(&self, layout: &mut LayoutStyle, paint: &mut PaintStyle) { +//! if let Some(font_size) = self.font_size { +//! layout.font_size = font_size; +//! } +//! if let Some(rgba) = self.rgba { +//! paint.rgba = rgba; +//! } +//! } +//! } +//! +//! let mut text = StyledTextBuilder::new( +//! LayoutStyle { font_size: 16.0 }, +//! PaintStyle { rgba: [0, 0, 0, 255] }, +//! ); +//! text.push("Hello "); +//! let styled_range = text.push_with( +//! "styled", +//! TextStyleChange { +//! font_size: Some(28.0), +//! ..TextStyleChange::default() +//! }, +//! ); +//! text.apply( +//! styled_range, +//! TextStyleChange { +//! rgba: Some([220, 40, 40, 255]), +//! ..TextStyleChange::default() +//! }, +//! ); +//! text.push(" text"); +//! let styled = text.finish(); +//! +//! let mut workspace = StyledSegmentsWorkspace::new(); +//! for segment in workspace.segments(&styled) { +//! let style = styled.style_set().segment_style(segment.style()); +//! // Feed segment.range() and style into layout or painting code. +//! } +//! ``` +//! +//! ## Features +//! +//! - `std` (enabled by default): Enables the `std` feature of +//! [`attributed_text`]. + +// LINEBENDER LINT SET - lib.rs - v3 +// See https://linebender.org/wiki/canonical-lints/ +// These lints shouldn't apply to examples or tests. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +// These lints shouldn't apply to examples. +#![warn(clippy::print_stdout, clippy::print_stderr)] +// Targeting e.g. 32-bit means structs containing usize can give false positives for 64-bit. +#![cfg_attr(target_pointer_width = "64", warn(clippy::trivially_copy_pass_by_ref))] +// END LINEBENDER LINT SET +#![cfg_attr(docsrs, feature(doc_cfg))] +#![no_std] + +extern crate alloc; + +mod segments; +mod style_set; +mod text; +mod text_builder; + +pub use attributed_text::{Error, TextChunk, TextRange, TextStorage}; +pub use segments::{StyledSegment, StyledSegmentsWorkspace}; +pub use style_set::{ + LayoutStyleId, PaintStyleId, SegmentStyle, StyleId, StyleRecord, StyleSet, StyleSetBuilder, +}; +pub use text::StyledText; +pub use text_builder::{StylePatch, StyledTextBuilder}; diff --git a/styled_text/src/segments.rs b/styled_text/src/segments.rs new file mode 100644 index 00000000..3131d564 --- /dev/null +++ b/styled_text/src/segments.rs @@ -0,0 +1,118 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use core::fmt::Debug; + +use attributed_text::{AttributeSegmentsWorkspace, TextRange, TextStorage}; + +use crate::{StyleId, StyledText}; + +/// A resolved styled segment. +/// +/// Segments are non-empty, contiguous byte ranges. Their style is the effective +/// style identifier after resolving overlapping applied style spans. The last +/// writer is the most recently applied span that is active for the segment. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StyledSegment { + range: TextRange, + style: StyleId, +} + +impl StyledSegment { + /// Returns the byte range covered by this segment. + #[must_use] + #[inline] + pub const fn range(self) -> TextRange { + self.range + } + + /// Returns the resolved compact style identifiers. + #[must_use] + #[inline] + pub const fn style(self) -> StyleId { + self.style + } +} + +/// Reusable allocation workspace for styled segment resolution. +/// +/// Reuse this value across layout or painting passes to amortize allocation in +/// the underlying attributed-text segmentation step. +#[derive(Clone, Debug, Default)] +pub struct StyledSegmentsWorkspace { + attributes: AttributeSegmentsWorkspace, +} + +impl StyledSegmentsWorkspace { + /// Creates an empty styled-segment workspace. + #[must_use] + #[inline] + pub fn new() -> Self { + Self::default() + } + + /// Iterates over resolved styled segments. + pub fn segments<'a, T, L, P>( + &'a mut self, + text: &'a StyledText, + ) -> impl Iterator + 'a + where + T: Debug + TextStorage, + { + let base_style = text.base_style(); + let mut inner = self.attributes.segments(text.attributed()); + + core::iter::from_fn(move || { + let segment = inner.next_segment()?; + let range = segment.range(); + // Active spans are in application order, so the last one wins. + let style = segment + .active_spans() + .iter() + .next_back() + .map_or(base_style, |(_range, style)| *style); + Some(StyledSegment { range, style }) + }) + } +} + +#[cfg(test)] +mod tests { + use alloc::sync::Arc; + use alloc::vec; + use alloc::vec::Vec; + + use super::StyledSegmentsWorkspace; + use crate::{StyleSetBuilder, StyledText}; + + #[test] + fn resolves_last_applied_style() { + let mut builder = StyleSetBuilder::<&'static str, &'static str>::new(); + let base = builder.intern_style("base", "black"); + let bold_red = builder.intern_style("bold", "red"); + let italic_blue = builder.intern_style("italic", "blue"); + let styles = Arc::new(builder.finish()); + + let mut text = StyledText::new("abcd", styles, base); + text.apply_style_bytes(0..3, bold_red) + .expect("valid style range"); + text.apply_style_bytes(1..2, italic_blue) + .expect("valid style range"); + + let mut workspace = StyledSegmentsWorkspace::new(); + let segments = workspace + .segments(&text) + .map(|segment| (segment.range().as_range(), segment.style())) + .collect::>(); + + assert_eq!( + segments, + vec![ + (0..1, bold_red), + (1..2, italic_blue), + (2..3, bold_red), + (3..4, base), + ] + ); + } +} diff --git a/styled_text/src/style_set.rs b/styled_text/src/style_set.rs new file mode 100644 index 00000000..a867a2df --- /dev/null +++ b/styled_text/src/style_set.rs @@ -0,0 +1,408 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use alloc::vec::Vec; +use core::fmt; + +/// Identifier for an interned layout-affecting style payload. +/// +/// Layout styles are intended for data that can affect shaping or line layout, +/// such as font size, font family, spacing, or wrapping policy. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct LayoutStyleId(u32); + +impl LayoutStyleId { + fn from_index(index: usize) -> Self { + let index = u32::try_from(index).expect("too many interned layout styles"); + Self(index) + } + + /// Returns this identifier as a zero-based table index. + #[must_use] + #[inline] + pub const fn index(self) -> usize { + self.0 as usize + } +} + +impl fmt::Debug for LayoutStyleId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("LayoutStyleId").field(&self.0).finish() + } +} + +/// Identifier for an interned paint-only style payload. +/// +/// Paint styles are intended for data that should not invalidate shaping, such +/// as color or renderer-specific paint metadata. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PaintStyleId(u32); + +impl PaintStyleId { + fn from_index(index: usize) -> Self { + let index = u32::try_from(index).expect("too many interned paint styles"); + Self(index) + } + + /// Returns this identifier as a zero-based table index. + #[must_use] + #[inline] + pub const fn index(self) -> usize { + self.0 as usize + } +} + +impl fmt::Debug for PaintStyleId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("PaintStyleId").field(&self.0).finish() + } +} + +/// Identifier for an interned full style. +/// +/// This is the compact style identity stored on text spans and resolved +/// segments. Internally, each full style points at separately interned layout +/// and paint payloads. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct StyleId(u32); + +impl StyleId { + fn from_index(index: usize) -> Self { + let index = u32::try_from(index).expect("too many interned styles"); + Self(index) + } + + /// Returns this identifier as a zero-based table index. + #[must_use] + #[inline] + pub const fn index(self) -> usize { + self.0 as usize + } +} + +impl fmt::Debug for StyleId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("StyleId").field(&self.0).finish() + } +} + +/// Interned component identifiers for a full style. +/// +/// This record is exposed for diagnostics, invalidation decisions, and adapters +/// that need to know whether two full styles share the same layout or paint +/// payload. Text spans should store [`StyleId`], not this component record. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct StyleRecord { + layout: LayoutStyleId, + paint: PaintStyleId, +} + +impl StyleRecord { + /// Returns the interned layout payload identifier. + #[must_use] + #[inline] + pub const fn layout_id(self) -> LayoutStyleId { + self.layout + } + + /// Returns the interned paint payload identifier. + #[must_use] + #[inline] + pub const fn paint_id(self) -> PaintStyleId { + self.paint + } +} + +/// Interned style payloads used by [`StyledText`](crate::StyledText). +/// +/// Text spans use one compact [`StyleId`]. This set keeps full style identity +/// stable while still deduplicating layout and paint payloads separately. +#[derive(Clone, Debug, Default)] +pub struct StyleSet { + styles: Vec, + layout: Vec, + paint: Vec

, +} + +impl StyleSet { + /// Creates an empty style set. + #[must_use] + #[inline] + pub const fn new() -> Self { + Self { + styles: Vec::new(), + layout: Vec::new(), + paint: Vec::new(), + } + } + + /// Returns the interned full-style record for `id`. + #[must_use] + #[inline] + pub fn get_style(&self, id: StyleId) -> Option { + self.styles.get(id.index()).copied() + } + + /// Returns the interned layout payload for `id`. + #[must_use] + #[inline] + pub fn get_layout(&self, id: LayoutStyleId) -> Option<&L> { + self.layout.get(id.index()) + } + + /// Returns the interned paint payload for `id`. + #[must_use] + #[inline] + pub fn get_paint(&self, id: PaintStyleId) -> Option<&P> { + self.paint.get(id.index()) + } + + /// Returns the number of interned full styles. + #[must_use] + #[inline] + pub fn style_len(&self) -> usize { + self.styles.len() + } + + /// Returns the number of interned layout payloads. + #[must_use] + #[inline] + pub fn layout_len(&self) -> usize { + self.layout.len() + } + + /// Returns the number of interned paint payloads. + #[must_use] + #[inline] + pub fn paint_len(&self) -> usize { + self.paint.len() + } + + /// Returns `true` if there are no interned full styles. + #[must_use] + #[inline] + pub fn is_empty(&self) -> bool { + self.styles.is_empty() + } + + /// Iterates over all full-style identifiers in table order. + pub fn style_ids(&self) -> impl ExactSizeIterator + Clone { + (0..self.styles.len()).map(StyleId::from_index) + } + + /// Resolves a compact style identifier into borrowed payloads from this table. + #[must_use] + pub fn segment_style(&self, id: StyleId) -> SegmentStyle<'_, L, P> { + let record = self.get_style(id).expect("style id must be interned"); + SegmentStyle { + id, + record, + layout: self + .get_layout(record.layout_id()) + .expect("layout style id must be interned"), + paint: self + .get_paint(record.paint_id()) + .expect("paint style id must be interned"), + } + } +} + +/// Borrowed style payloads for a resolved segment. +#[derive(Debug, PartialEq)] +pub struct SegmentStyle<'a, L, P> { + id: StyleId, + record: StyleRecord, + layout: &'a L, + paint: &'a P, +} + +impl Clone for SegmentStyle<'_, L, P> { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for SegmentStyle<'_, L, P> {} + +impl<'a, L, P> SegmentStyle<'a, L, P> { + /// Returns the compact full-style identifier. + #[must_use] + #[inline] + pub const fn id(self) -> StyleId { + self.id + } + + /// Returns the interned component identifiers for this full style. + #[must_use] + #[inline] + pub const fn record(self) -> StyleRecord { + self.record + } + + /// Returns the resolved layout-style identifier. + #[must_use] + #[inline] + pub const fn layout_id(self) -> LayoutStyleId { + self.record.layout_id() + } + + /// Returns the resolved paint-style identifier. + #[must_use] + #[inline] + pub const fn paint_id(self) -> PaintStyleId { + self.record.paint_id() + } + + /// Returns the resolved layout payload. + #[must_use] + #[inline] + pub const fn layout(self) -> &'a L { + self.layout + } + + /// Returns the resolved paint payload. + #[must_use] + #[inline] + pub const fn paint(self) -> &'a P { + self.paint + } +} + +/// Builder for [`StyleSet`] values. +/// +/// Interning is currently a linear search over retained vectors. That keeps the +/// first slice dependency-free and compact; callers can reuse a builder during +/// style collection to amortize allocations. +#[derive(Clone, Debug, Default)] +pub struct StyleSetBuilder { + styles: Vec, + layout: Vec, + paint: Vec

, +} + +impl StyleSetBuilder { + /// Creates an empty style-set builder. + #[must_use] + #[inline] + pub const fn new() -> Self { + Self { + styles: Vec::new(), + layout: Vec::new(), + paint: Vec::new(), + } + } + + /// Creates a builder with capacity for full styles and their component payloads. + #[must_use] + #[inline] + pub fn with_capacity(style_capacity: usize) -> Self { + Self { + styles: Vec::with_capacity(style_capacity), + layout: Vec::with_capacity(style_capacity), + paint: Vec::with_capacity(style_capacity), + } + } + + /// Removes all interned styles and payloads while keeping allocated storage. + #[inline] + pub fn clear(&mut self) { + self.styles.clear(); + self.layout.clear(); + self.paint.clear(); + } + + /// Finishes the builder and returns the style set. + #[must_use] + #[inline] + pub fn finish(self) -> StyleSet { + StyleSet { + styles: self.styles, + layout: self.layout, + paint: self.paint, + } + } + + fn intern_style_record(&mut self, record: StyleRecord) -> StyleId { + if let Some(index) = self.styles.iter().position(|existing| *existing == record) { + return StyleId::from_index(index); + } + + let index = self.styles.len(); + self.styles.push(record); + StyleId::from_index(index) + } +} + +impl StyleSetBuilder { + fn intern_layout(&mut self, style: L) -> LayoutStyleId { + if let Some(index) = self.layout.iter().position(|existing| existing == &style) { + return LayoutStyleId::from_index(index); + } + + let index = self.layout.len(); + self.layout.push(style); + LayoutStyleId::from_index(index) + } +} + +impl StyleSetBuilder { + fn intern_paint(&mut self, style: P) -> PaintStyleId { + if let Some(index) = self.paint.iter().position(|existing| existing == &style) { + return PaintStyleId::from_index(index); + } + + let index = self.paint.len(); + self.paint.push(style); + PaintStyleId::from_index(index) + } +} + +impl StyleSetBuilder { + /// Interns layout and paint payloads as a full style and returns its identifier. + pub fn intern_style(&mut self, layout: L, paint: P) -> StyleId { + let layout = self.intern_layout(layout); + let paint = self.intern_paint(paint); + self.intern_style_record(StyleRecord { layout, paint }) + } +} + +#[cfg(test)] +mod tests { + use alloc::string::String; + + use super::StyleSetBuilder; + + #[test] + fn builder_interns_equal_payloads_and_full_styles() { + let mut builder = StyleSetBuilder::::new(); + let normal_red = builder.intern_style(12, 0xff00_u16); + let normal_red_again = builder.intern_style(12, 0xff00_u16); + let normal_blue = builder.intern_style(12, 0x00ff_u16); + + assert_eq!(normal_red, normal_red_again); + assert_ne!(normal_red, normal_blue); + + let styles = builder.finish(); + assert_eq!(styles.style_len(), 2); + assert_eq!(styles.layout_len(), 1); + assert_eq!(styles.paint_len(), 2); + + let style = styles.segment_style(normal_red); + assert_eq!(style.layout(), &12); + assert_eq!(style.paint(), &0xff00_u16); + } + + #[test] + fn segment_style_is_copy_for_non_copy_payloads() { + let mut builder = StyleSetBuilder::::new(); + let style_id = builder.intern_style(String::from("layout"), String::from("paint")); + let styles = builder.finish(); + + let style = styles.segment_style(style_id); + let layout = style.layout(); + let paint = style.paint(); + + assert_eq!(layout, "layout"); + assert_eq!(paint, "paint"); + } +} diff --git a/styled_text/src/text.rs b/styled_text/src/text.rs new file mode 100644 index 00000000..9f4e5037 --- /dev/null +++ b/styled_text/src/text.rs @@ -0,0 +1,199 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use alloc::sync::Arc; +use core::fmt::Debug; +use core::ops::Range; + +use attributed_text::{AttributedText, Error, TextChunk, TextRange, TextStorage}; + +use crate::{StyleId, StyleSet}; + +/// Text plus compact style spans. +/// +/// The text storage can be contiguous (`&str`, `String`, `Arc`) or chunked. +/// Style payloads live in a shared [`StyleSet`], while each applied span stores +/// only compact identifiers. +#[derive(Debug)] +pub struct StyledText { + attributed: AttributedText, + styles: Arc>, + base_style: StyleId, +} + +impl StyledText { + pub(crate) fn from_attributed_parts( + attributed: AttributedText, + styles: Arc>, + base_style: StyleId, + ) -> Self { + Self { + attributed, + styles, + base_style, + } + } + + /// Creates styled text from text storage, a shared style set, and a base style. + /// + /// The base style identifier must come from `styles`. + #[must_use] + pub fn new(text: T, styles: Arc>, base_style: StyleId) -> Self { + debug_assert!( + styles.get_style(base_style).is_some(), + "base style id must be interned in styles" + ); + Self::from_attributed_parts(AttributedText::new(text), styles, base_style) + } + + /// Borrow the underlying attributed text. + #[must_use] + #[inline] + pub const fn attributed(&self) -> &AttributedText { + &self.attributed + } + + /// Borrow the underlying text storage. + #[must_use] + #[inline] + pub fn text(&self) -> &T { + self.attributed.text() + } + + /// Replaces the underlying text and clears applied style spans. + #[inline] + pub fn set_text(&mut self, text: T) { + self.attributed.set_text(text); + } + + /// Borrow the shared style set. + #[must_use] + #[inline] + pub fn style_set(&self) -> &StyleSet { + &self.styles + } + + /// Borrow the shared style set handle. + #[must_use] + #[inline] + pub fn style_set_arc(&self) -> &Arc> { + &self.styles + } + + /// Returns the base style used when no active span is present. + #[must_use] + #[inline] + pub const fn base_style(&self) -> StyleId { + self.base_style + } + + /// Updates the base style used by segment resolution. + /// + /// The style identifier must come from this text's [`StyleSet`]. + #[inline] + pub fn set_base_style(&mut self, base_style: StyleId) { + self.debug_assert_style_is_in_set(base_style); + self.base_style = base_style; + } + + /// Returns the text length in bytes. + #[must_use] + #[inline] + pub fn len(&self) -> usize { + self.attributed.len() + } + + /// Returns `true` if the underlying text is empty. + #[must_use] + #[inline] + pub fn is_empty(&self) -> bool { + self.attributed.is_empty() + } + + /// Borrow the underlying text as `&str` when the storage is contiguous. + #[must_use] + #[inline] + pub fn as_str(&self) -> Option<&str> { + self.attributed.as_str() + } + + /// Iterates over borrowed text chunks covering `range`. + pub fn chunks(&self, range: TextRange) -> impl Iterator> { + self.attributed.chunks(range) + } + + /// Validates a byte range against the underlying text storage. + #[inline] + pub fn validate_range(&self, range: Range) -> Result { + self.text().validate_range(range) + } + + /// Applies compact style identifiers to a validated range. + /// + /// The style identifier must come from this text's [`StyleSet`]. + #[inline] + pub fn apply_style(&mut self, range: TextRange, style: StyleId) { + self.debug_assert_style_is_in_set(style); + self.attributed.apply_attribute(range, style); + } + + /// Applies compact style identifiers to a byte range after validation. + /// + /// The style identifier must come from this text's [`StyleSet`]. + #[inline] + pub fn apply_style_bytes(&mut self, range: Range, style: StyleId) -> Result<(), Error> { + let range = self.validate_range(range)?; + self.apply_style(range, style); + Ok(()) + } + + /// Removes all applied style spans. + #[inline] + pub fn clear_styles(&mut self) { + self.attributed.clear_attributes(); + } + + /// Returns the number of applied style spans. + #[must_use] + #[inline] + pub fn style_spans_len(&self) -> usize { + self.attributed.attributes_len() + } + + /// Iterates over all applied style spans in application order. + #[inline] + pub fn style_spans(&self) -> impl ExactSizeIterator { + self.attributed.attributes_iter() + } + + fn debug_assert_style_is_in_set(&self, style: StyleId) { + debug_assert!( + self.styles.get_style(style).is_some(), + "style id must be interned in this text's style set" + ); + } +} + +#[cfg(test)] +mod tests { + use alloc::sync::Arc; + + use crate::{StyleSetBuilder, StyledText}; + + #[cfg(debug_assertions)] + #[test] + #[should_panic(expected = "style id must be interned in this text's style set")] + fn apply_style_rejects_out_of_set_style_in_debug() { + let mut first = StyleSetBuilder::::new(); + let base = first.intern_style(0, ()); + let styles = Arc::new(first.finish()); + + let mut second = StyleSetBuilder::::new(); + second.intern_style(0, ()); + let foreign = second.intern_style(1, ()); + + let mut text = StyledText::new("abc", styles, base); + let range = text.validate_range(0..1).expect("valid range"); + text.apply_style(range, foreign); + } +} diff --git a/styled_text/src/text_builder.rs b/styled_text/src/text_builder.rs new file mode 100644 index 00000000..8806ff1a --- /dev/null +++ b/styled_text/src/text_builder.rs @@ -0,0 +1,393 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use alloc::string::String; +use alloc::sync::Arc; +use alloc::vec::Vec; +use core::ops::Range; + +use attributed_text::{AttributeSegmentsWorkspace, AttributedText, Error, TextRange, TextStorage}; + +use crate::{StyleId, StyleSetBuilder, StyledText}; + +/// A partial style patch that can be applied to full layout and paint styles. +/// +/// This trait is intentionally generic. Toolkits can define their own patch +/// type with whatever fields make sense, then merge those patches into their +/// own full style payloads during [`StyledTextBuilder::finish`]. +pub trait StylePatch { + /// Applies this patch to the current full layout and paint styles. + fn apply_to(&self, layout: &mut L, paint: &mut P); +} + +impl StylePatch for () { + #[inline] + fn apply_to(&self, _layout: &mut L, _paint: &mut P) {} +} + +/// Builder for constructing compact styled text from text and style patches. +/// +/// This is the higher-level construction API. Callers append text and record +/// partial style patches against byte ranges. At [`finish`](Self::finish), the +/// builder composes overlapping patches in the order they were applied, +/// interns the resulting full layout and paint payloads, then returns +/// [`StyledText`]. +/// +/// The resolved output remains the low-level representation: text plus compact +/// [`StyleId`] spans. Individual style fields are a construction detail of the +/// caller's patch type; the stored spans refer to complete styles. +#[derive(Debug)] +pub struct StyledTextBuilder { + text: String, + patches: Vec<(TextRange, Patch)>, + base_layout: L, + base_paint: P, +} + +impl StyledTextBuilder { + /// Creates an empty builder with base layout and paint styles. + #[must_use] + pub fn new(base_layout: L, base_paint: P) -> Self { + Self { + text: String::new(), + patches: Vec::new(), + base_layout, + base_paint, + } + } + + /// Creates an empty builder with retained capacity for text and patches. + #[must_use] + pub fn with_capacity( + base_layout: L, + base_paint: P, + text_capacity: usize, + patch_capacity: usize, + ) -> Self { + Self { + text: String::with_capacity(text_capacity), + patches: Vec::with_capacity(patch_capacity), + base_layout, + base_paint, + } + } + + /// Reserves capacity for additional text bytes and style patches. + /// + /// This is useful when the text length is known but the patch type should be + /// inferred from later [`push_with`](Self::push_with) or [`apply`](Self::apply) + /// calls. + pub fn reserve(&mut self, additional_text: usize, additional_patches: usize) { + self.text.reserve(additional_text); + self.patches.reserve(additional_patches); + } + + /// Creates a builder from existing text. + #[must_use] + pub fn from_text(text: impl Into, base_layout: L, base_paint: P) -> Self { + Self { + text: text.into(), + patches: Vec::new(), + base_layout, + base_paint, + } + } + + /// Returns the current text. + #[must_use] + #[inline] + pub fn text(&self) -> &str { + &self.text + } + + /// Returns the current text length in bytes. + #[must_use] + #[inline] + pub fn len(&self) -> usize { + self.text.len() + } + + /// Returns `true` if the current text is empty. + #[must_use] + #[inline] + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + + /// Returns the number of applied style patches. + #[must_use] + #[inline] + pub fn patch_len(&self) -> usize { + self.patches.len() + } + + /// Validates a byte range against the current text. + #[inline] + pub fn validate_range(&self, range: Range) -> Result { + self.text.validate_range(range) + } + + /// Appends unstyled text and returns its range. + /// + /// "Unstyled" means no additional patch is applied; the range still inherits + /// the base style and any later overlapping patches. + pub fn push(&mut self, text: &str) -> TextRange { + let start = self.text.len(); + self.text.push_str(text); + TextRange::new_unchecked(start, self.text.len()) + } + + /// Applies a patch to a validated range. + pub fn apply(&mut self, range: TextRange, patch: Patch) { + if !range.is_empty() { + self.patches.push((range, patch)); + } + } + + /// Applies a patch to a byte range after validation. + pub fn apply_bytes(&mut self, range: Range, patch: Patch) -> Result<(), Error> { + let range = self.validate_range(range)?; + self.apply(range, patch); + Ok(()) + } + + /// Appends text, applies a patch to that appended range, and returns the range. + pub fn push_with(&mut self, text: &str, patch: Patch) -> TextRange { + let range = self.push(text); + self.apply(range, patch); + range + } +} + +impl StyledTextBuilder +where + L: Clone + PartialEq, + P: Clone + PartialEq, + Patch: StylePatch, +{ + /// Finishes the builder and returns compact resolved styled text. + /// + /// Overlapping patches are applied in the order they were applied to the + /// builder over a clone of the base layout and paint styles for each + /// resolved non-base segment. + #[must_use] + pub fn finish(self) -> StyledText { + let Self { + text, + patches, + base_layout, + base_paint, + } = self; + + let mut style_builder = StyleSetBuilder::with_capacity(patches.len().saturating_add(1)); + let base_style = style_builder.intern_style(base_layout.clone(), base_paint.clone()); + + let resolved_text = if patches.is_empty() { + AttributedText::new(text) + } else { + let mut resolved_spans = Vec::new(); + let mut workspace = AttributeSegmentsWorkspace::new(); + let mut pending: Option<(TextRange, StyleId)> = None; + + workspace.for_each_span_segment_unchecked(text.len(), &patches, |range, active| { + if active.is_empty() { + if let Some(pending) = pending.take() { + resolved_spans.push(pending); + } + return; + } + + let mut layout = base_layout.clone(); + let mut paint = base_paint.clone(); + for &patch_index in active { + patches[patch_index as usize] + .1 + .apply_to(&mut layout, &mut paint); + } + + let style = style_builder.intern_style(layout, paint); + if style == base_style { + if let Some(pending) = pending.take() { + resolved_spans.push(pending); + } + return; + } + + match pending.take() { + Some((pending_range, pending_style)) + if pending_style == style && pending_range.end() == range.start() => + { + pending = Some(( + TextRange::new_unchecked(pending_range.start(), range.end()), + style, + )); + } + Some(pending_span) => { + resolved_spans.push(pending_span); + pending = Some((range, style)); + } + None => { + pending = Some((range, style)); + } + } + }); + + if let Some(pending) = pending { + resolved_spans.push(pending); + } + + AttributedText::from_attributes_unchecked(text, resolved_spans) + }; + + StyledText::from_attributed_parts( + resolved_text, + Arc::new(style_builder.finish()), + base_style, + ) + } +} + +#[cfg(test)] +mod tests { + use alloc::vec; + use alloc::vec::Vec; + use core::sync::atomic::{AtomicUsize, Ordering}; + + use crate::{StylePatch, StyledSegmentsWorkspace, StyledTextBuilder}; + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct Layout { + size: u8, + underline: bool, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct Paint(u8); + + #[derive(Clone, Copy, Default)] + struct Patch { + size: Option, + underline: Option, + paint: Option, + } + + impl StylePatch for Patch { + fn apply_to(&self, layout: &mut Layout, paint: &mut Paint) { + if let Some(size) = self.size { + layout.size = size; + } + if let Some(underline) = self.underline { + layout.underline = underline; + } + if let Some(color) = self.paint { + paint.0 = color; + } + } + } + + static LAYOUT_CLONES: AtomicUsize = AtomicUsize::new(0); + + #[derive(Debug, PartialEq, Eq)] + struct CountedLayout(u8); + + impl Clone for CountedLayout { + fn clone(&self) -> Self { + LAYOUT_CLONES.fetch_add(1, Ordering::Relaxed); + Self(self.0) + } + } + + #[derive(Clone, Debug, PartialEq, Eq)] + struct CountedPaint(u8); + + #[derive(Clone, Copy)] + struct CountedPatch(u8); + + impl StylePatch for CountedPatch { + fn apply_to(&self, layout: &mut CountedLayout, _paint: &mut CountedPaint) { + layout.0 = self.0; + } + } + + #[test] + fn builder_merges_overlapping_patches_by_property() { + let base_layout = Layout { + size: 16, + underline: false, + }; + let mut builder = StyledTextBuilder::new(base_layout, Paint(1)); + let all = builder.push("abcd"); + builder.apply( + all, + Patch { + size: Some(12), + ..Patch::default() + }, + ); + builder + .apply_bytes( + 1..3, + Patch { + underline: Some(true), + paint: Some(2), + ..Patch::default() + }, + ) + .expect("valid range"); + + let styled = builder.finish(); + let mut workspace = StyledSegmentsWorkspace::new(); + let segments = workspace + .segments(&styled) + .map(|segment| { + let style = styled.style_set().segment_style(segment.style()); + (segment.range().as_range(), *style.layout(), *style.paint()) + }) + .collect::>(); + + assert_eq!( + segments, + vec![ + ( + 0..1, + Layout { + size: 12, + underline: false + }, + Paint(1) + ), + ( + 1..3, + Layout { + size: 12, + underline: true + }, + Paint(2) + ), + ( + 3..4, + Layout { + size: 12, + underline: false + }, + Paint(1) + ), + ] + ); + } + + #[test] + fn builder_skips_base_style_clone_for_unpatched_segments() { + let mut builder = StyledTextBuilder::new(CountedLayout(1), CountedPaint(1)); + builder.push("abcdef"); + builder + .apply_bytes(2..4, CountedPatch(2)) + .expect("valid range"); + + LAYOUT_CLONES.store(0, Ordering::Relaxed); + let styled = builder.finish(); + + assert_eq!(LAYOUT_CLONES.load(Ordering::Relaxed), 2); + assert_eq!(styled.style_set().style_len(), 2); + } +} diff --git a/styled_text_parley/CHANGELOG.md b/styled_text_parley/CHANGELOG.md new file mode 100644 index 00000000..5062d464 --- /dev/null +++ b/styled_text_parley/CHANGELOG.md @@ -0,0 +1,19 @@ + + +# Changelog + +No `styled_text_parley` release from this repository has been published yet. + +## [Unreleased] + +This release has an [MSRV] of 1.88. + +[Unreleased]: https://github.com/linebender/parley/commits/main/styled_text_parley + +[MSRV]: README.md#minimum-supported-rust-version-msrv diff --git a/styled_text_parley/Cargo.toml b/styled_text_parley/Cargo.toml new file mode 100644 index 00000000..ea92c39d --- /dev/null +++ b/styled_text_parley/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "styled_text_parley" +version = "0.1.0" +description = "Parley adapter for styled_text style runs" +keywords = ["text", "layout", "style"] +categories = ["graphics", "text"] +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +publish = false + +[package.metadata.docs.rs] +all-features = true +default-target = "x86_64-unknown-linux-gnu" +targets = [] + +[features] +default = ["std"] +libm = ["parley/libm"] +std = ["parley/std", "styled_text/std"] + +[dependencies] +parley = { workspace = true } +styled_text = { path = "../styled_text", default-features = false } + +[lints] +workspace = true diff --git a/styled_text_parley/LICENSE-APACHE b/styled_text_parley/LICENSE-APACHE new file mode 100644 index 00000000..d9a10c0d --- /dev/null +++ b/styled_text_parley/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/styled_text_parley/LICENSE-MIT b/styled_text_parley/LICENSE-MIT new file mode 100644 index 00000000..9cf10627 --- /dev/null +++ b/styled_text_parley/LICENSE-MIT @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/styled_text_parley/README.md b/styled_text_parley/README.md new file mode 100644 index 00000000..25a2bf0e --- /dev/null +++ b/styled_text_parley/README.md @@ -0,0 +1,144 @@ +

+ +# Styled Text Parley + +Parley lowering for compact styled_text style runs. + +[![Linebender Zulip, #parley channel](https://img.shields.io/badge/Linebender-%23parley-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/channel/205635-parley) +[![dependency status](https://deps.rs/repo/github/linebender/parley/status.svg)](https://deps.rs/repo/github/linebender/parley) +[![Apache 2.0 or MIT license.](https://img.shields.io/badge/license-Apache--2.0_OR_MIT-blue.svg)](#license) +[![Build status](https://github.com/linebender/parley/workflows/CI/badge.svg)](https://github.com/linebender/parley/actions) +[![Crates.io](https://img.shields.io/crates/v/styled_text_parley.svg)](https://crates.io/crates/styled_text_parley) +[![Docs](https://docs.rs/styled_text_parley/badge.svg)](https://docs.rs/styled_text_parley) + +
+ + + + + + +Styled Text Parley adapts [`styled_text`] to Parley's low-level style-run +builder. +It lowers resolved styled-text segments into Parley's style table and range +runs, while reusing scratch storage across layout builds. + +The crate also provides a Parley-shaped first style vocabulary. +[`ParleyLayoutStyle`] holds the fields that can affect shaping and line +layout. +[`ParleyPaintStyle`] holds paint-only fields such as brushes and decorations. +Interning those payloads separately means paint-only changes can share +layout identity when the styled text is lowered. + +This adapter does not own document structure, inline boxes, cascading, or +renderer-specific style semantics. +Callers can use the provided Parley style payloads for a simple path, or keep +their own style types in `styled_text` and use the generic lowering +functions. + +## Concepts + +- [`ParleyStyledTextBuilder`] is a [`StyledTextBuilder`] configured with the + default Parley style payloads and patch type. +- [`ParleyStyleChange`] is a partial style patch for common Parley fields, + with public fields for less common changes. +- [`ParleyStyleRunWorkspace`] keeps the reusable segment workspace and the + temporary [`styled_text::StyleId`] to Parley style-index map. +- [`build_layout_from_parley_styled_text`] creates a Parley [`parley::Layout`] + from text built with the default Parley payloads. +- [`push_style_runs`] is the lower-level hook for callers that want to feed + Parley style runs themselves. + +## Building a Parley layout + +```rust +use parley::{FontContext, FontWeight, LayoutContext}; +use styled_text_parley::{ + ParleyLayoutStyle, ParleyPaintStyle, ParleyStyleChange, ParleyStyleRunWorkspace, + ParleyStyledTextBuilder, build_layout_from_parley_styled_text, +}; + +let mut text = ParleyStyledTextBuilder::<()>::new( + ParleyLayoutStyle::default(), + ParleyPaintStyle::default(), +); +text.push("Hello "); +text.push_with( + "styled text", + ParleyStyleChange::default() + .font_size(24.0) + .font_weight(FontWeight::BOLD), +); +let styled = text.finish(); + +let mut font_cx = FontContext::new(); +let mut layout_cx = LayoutContext::<()>::new(); +let mut workspace = ParleyStyleRunWorkspace::new(); +let mut layout = build_layout_from_parley_styled_text( + &mut layout_cx, + &mut font_cx, + &styled, + &mut workspace, + 1.0, + true, +).unwrap(); +layout.break_all_lines(Some(240.0)); +``` + +## Features + +- `std` (enabled by default): Enables `std` support in [`parley`] and + [`styled_text`]. +- `libm`: Enables the `libm` feature of [`parley`]. + + + +## Minimum supported Rust Version (MSRV) + +This version of Styled Text Parley has been verified to compile with **Rust 1.88** and later. + +Future versions of Styled Text Parley might increase the Rust version requirement. +It will not be treated as a breaking change and as such can even happen with small patch releases. + +
+Click here if compiling fails. + +As time has passed, some of Styled Text Parley's dependencies could have released versions with a higher Rust requirement. +If you encounter a compilation issue due to a dependency and don't want to upgrade your Rust toolchain, then you could downgrade the dependency. + +```sh +# Use the problematic dependency's name and version +cargo update -p package_name --precise 0.1.1 +``` + +
+ +## Community + +[![Linebender Zulip](https://img.shields.io/badge/Xi%20Zulip-%23parley-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/channel/205635-parley) + +Discussion of Styled Text Parley development happens in the [Linebender Zulip](https://xi.zulipchat.com/), specifically the [#parley channel](https://xi.zulipchat.com/#narrow/channel/205635-parley). +All public content can be read without logging in. + +## License + +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or ) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or ) + +at your option. + +## Contribution + +Contributions are welcome by pull request. The [Rust code of conduct] applies. +Please feel free to add your name to the [AUTHORS] file in any substantive pull request. + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be licensed as above, without any additional terms or conditions. + +[Rust Code of Conduct]: https://www.rust-lang.org/policies/code-of-conduct +[AUTHORS]: ../AUTHORS diff --git a/styled_text_parley/src/lib.rs b/styled_text_parley/src/lib.rs new file mode 100644 index 00000000..1296e6dc --- /dev/null +++ b/styled_text_parley/src/lib.rs @@ -0,0 +1,107 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Styled Text Parley adapts [`styled_text`] to Parley's low-level style-run +//! builder. +//! It lowers resolved styled-text segments into Parley's style table and range +//! runs, while reusing scratch storage across layout builds. +//! +//! The crate also provides a Parley-shaped first style vocabulary. +//! [`ParleyLayoutStyle`] holds the fields that can affect shaping and line +//! layout. +//! [`ParleyPaintStyle`] holds paint-only fields such as brushes and decorations. +//! Interning those payloads separately means paint-only changes can share +//! layout identity when the styled text is lowered. +//! +//! This adapter does not own document structure, inline boxes, cascading, or +//! renderer-specific style semantics. +//! Callers can use the provided Parley style payloads for a simple path, or keep +//! their own style types in `styled_text` and use the generic lowering +//! functions. +//! +//! ## Concepts +//! +//! - [`ParleyStyledTextBuilder`] is a [`StyledTextBuilder`] configured with the +//! default Parley style payloads and patch type. +//! - [`ParleyStyleChange`] is a partial style patch for common Parley fields, +//! with public fields for less common changes. +//! - [`ParleyStyleRunWorkspace`] keeps the reusable segment workspace and the +//! temporary [`styled_text::StyleId`] to Parley style-index map. +//! - [`build_layout_from_parley_styled_text`] creates a Parley [`parley::Layout`] +//! from text built with the default Parley payloads. +//! - [`push_style_runs`] is the lower-level hook for callers that want to feed +//! Parley style runs themselves. +//! +//! ## Building a Parley layout +//! +//! ```no_run +//! use parley::{FontContext, FontWeight, LayoutContext}; +//! use styled_text_parley::{ +//! ParleyLayoutStyle, ParleyPaintStyle, ParleyStyleChange, ParleyStyleRunWorkspace, +//! ParleyStyledTextBuilder, build_layout_from_parley_styled_text, +//! }; +//! +//! let mut text = ParleyStyledTextBuilder::<()>::new( +//! ParleyLayoutStyle::default(), +//! ParleyPaintStyle::default(), +//! ); +//! text.push("Hello "); +//! text.push_with( +//! "styled text", +//! ParleyStyleChange::default() +//! .font_size(24.0) +//! .font_weight(FontWeight::BOLD), +//! ); +//! let styled = text.finish(); +//! +//! let mut font_cx = FontContext::new(); +//! let mut layout_cx = LayoutContext::<()>::new(); +//! let mut workspace = ParleyStyleRunWorkspace::new(); +//! let mut layout = build_layout_from_parley_styled_text( +//! &mut layout_cx, +//! &mut font_cx, +//! &styled, +//! &mut workspace, +//! 1.0, +//! true, +//! ).unwrap(); +//! layout.break_all_lines(Some(240.0)); +//! ``` +//! +//! ## Features +//! +//! - `std` (enabled by default): Enables `std` support in [`parley`] and +//! [`styled_text`]. +//! - `libm`: Enables the `libm` feature of [`parley`]. + +// LINEBENDER LINT SET - lib.rs - v3 +// See https://linebender.org/wiki/canonical-lints/ +// These lints shouldn't apply to examples or tests. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +// These lints shouldn't apply to examples. +#![warn(clippy::print_stdout, clippy::print_stderr)] +// Targeting e.g. 32-bit means structs containing usize can give false positives for 64-bit. +#![cfg_attr(target_pointer_width = "64", warn(clippy::trivially_copy_pass_by_ref))] +// END LINEBENDER LINT SET +#![cfg_attr(docsrs, feature(doc_cfg))] +#![no_std] + +extern crate alloc; + +mod lowering; +mod style; + +use styled_text::{StyledText, StyledTextBuilder}; + +pub use lowering::{ + Error, ParleyStyleRunWorkspace, build_layout_from_parley_styled_text, + build_layout_from_styled_text, push_parley_style, push_style_runs, +}; +pub use style::{ParleyLayoutStyle, ParleyPaintStyle, ParleyStyleChange}; + +/// Styled text that uses the default Parley style payloads. +pub type ParleyStyledText = StyledText>; + +/// Builder for styled text that uses the default Parley style payloads. +pub type ParleyStyledTextBuilder = + StyledTextBuilder, ParleyStyleChange>; diff --git a/styled_text_parley/src/lowering.rs b/styled_text_parley/src/lowering.rs new file mode 100644 index 00000000..0c148283 --- /dev/null +++ b/styled_text_parley/src/lowering.rs @@ -0,0 +1,320 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use alloc::vec::Vec; +use core::fmt::{self, Debug}; + +use parley::{Brush, FontContext, Layout, LayoutContext, StyleRunBuilder, TextStyle}; +use styled_text::{ + SegmentStyle, StyleId, StyledSegmentsWorkspace, StyledText, TextRange, TextStorage, +}; + +use crate::{ParleyLayoutStyle, ParleyPaintStyle, ParleyStyledText}; + +/// Reusable allocation workspace for lowering styled text into Parley style runs. +/// +/// Reuse this across layout builds to retain both the styled-segment workspace +/// and the temporary map from [`styled_text::StyleId`] to Parley `u16` style indices. +#[derive(Clone, Debug, Default)] +pub struct ParleyStyleRunWorkspace { + segments: StyledSegmentsWorkspace, + style_indices: Vec, +} + +impl ParleyStyleRunWorkspace { + /// Creates an empty workspace. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Clears retained style-index data while keeping allocations for reuse. + /// + /// Segment scratch data is rebuilt the next time the workspace is used. + pub fn clear(&mut self) { + self.style_indices.clear(); + } +} + +/// Error returned when styled text cannot be lowered to Parley. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Error { + /// The styled text is not backed by one contiguous string. + /// + /// Parley's current style-run builder accepts a single `&str`; chunked text + /// storage should be flattened or handled by a future chunk-aware adapter. + NonContiguousText, + /// The styled text style table is too large for Parley's `u16` style + /// indices. + TooManyStyles { + /// Number of interned styled-text styles. + count: usize, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NonContiguousText => { + f.write_str("styled text is not backed by one contiguous string") + } + Self::TooManyStyles { count } => { + write!( + f, + "styled text has {count} styles, but Parley supports at most \ + {MAX_PARLEY_STYLES}" + ) + } + } + } +} + +impl core::error::Error for Error {} + +/// Pushes resolved styled-text segments into an existing Parley style-run builder. +/// +/// The `push_style` callback receives the Parley builder and each interned full +/// styled-text style in [`styled_text::StyleId`] table order. It must push a +/// Parley style and return the style-table index produced by +/// [`StyleRunBuilder::push_style`]. +/// +/// This pushes every interned styled-text style, including styles not currently +/// used by any resolved segment. That preserves the simple table-order lowering +/// contract without allocating a filtered style table. The temporary +/// styled-text-to-Parley style-index map is stored in `workspace` and reused +/// across calls. +pub fn push_style_runs( + builder: &mut StyleRunBuilder<'_, B>, + styled: &StyledText, + workspace: &mut ParleyStyleRunWorkspace, + mut push_style: F, +) -> Result<(), Error> +where + T: Debug + TextStorage, + B: Brush, + F: FnMut(&mut StyleRunBuilder<'_, B>, SegmentStyle<'_, L, P>) -> u16, +{ + let style_count = styled.style_set().style_len(); + workspace.style_indices.clear(); + check_style_count(style_count)?; + let max_runs = styled.style_spans_len().saturating_mul(2).saturating_add(1); + builder.reserve(style_count, max_runs); + workspace.style_indices.reserve(style_count); + + for style_id in styled.style_set().style_ids() { + let style = styled.style_set().segment_style(style_id); + workspace.style_indices.push(push_style(builder, style)); + } + + let mut pending: Option<(TextRange, StyleId)> = None; + for segment in workspace.segments.segments(styled) { + let range = segment.range(); + let style = segment.style(); + match pending.take() { + Some((pending_range, pending_style)) + if pending_style == style && pending_range.end() == range.start() => + { + pending = Some(( + TextRange::new_unchecked(pending_range.start(), range.end()), + pending_style, + )); + } + Some((pending_range, pending_style)) => { + let style_index = workspace.style_indices[pending_style.index()]; + builder.push_style_run(style_index, pending_range.as_range()); + pending = Some((range, style)); + } + None => { + pending = Some((range, style)); + } + } + } + if let Some((range, style)) = pending { + let style_index = workspace.style_indices[style.index()]; + builder.push_style_run(style_index, range.as_range()); + } + + Ok(()) +} + +/// Pushes one default Parley style payload into a Parley style-run builder. +/// +/// This is the [`push_style_runs`] callback for [`ParleyStyledText`]. +pub fn push_parley_style( + builder: &mut StyleRunBuilder<'_, B>, + style: SegmentStyle<'_, ParleyLayoutStyle, ParleyPaintStyle>, +) -> u16 { + let layout = style.layout(); + let paint = style.paint(); + builder.push_style(TextStyle { + font_family: layout.font_family.clone(), + font_size: layout.font_size, + font_width: layout.font_width, + font_style: layout.font_style, + font_weight: layout.font_weight, + font_variations: layout.font_variations.clone(), + font_features: layout.font_features.clone(), + locale: layout.locale, + brush: paint.brush.clone(), + has_underline: paint.has_underline, + underline_offset: paint.underline_offset, + underline_size: paint.underline_size, + underline_brush: paint.underline_brush.clone(), + has_strikethrough: paint.has_strikethrough, + strikethrough_offset: paint.strikethrough_offset, + strikethrough_size: paint.strikethrough_size, + strikethrough_brush: paint.strikethrough_brush.clone(), + line_height: layout.line_height, + word_spacing: layout.word_spacing, + letter_spacing: layout.letter_spacing, + word_break: layout.word_break, + overflow_wrap: layout.overflow_wrap, + text_wrap_mode: layout.text_wrap_mode, + }) +} + +const MAX_PARLEY_STYLES: usize = u16::MAX as usize + 1; + +fn check_style_count(count: usize) -> Result<(), Error> { + if count > MAX_PARLEY_STYLES { + return Err(Error::TooManyStyles { count }); + } + Ok(()) +} + +/// Builds a Parley layout from styled text backed by a contiguous string. +/// +/// The callback has the same contract as [`push_style_runs`]. This helper is +/// intentionally thin so callers remain in control of how their interned style +/// payloads become Parley [`parley::TextStyle`] values. +pub fn build_layout_from_styled_text( + layout_cx: &mut LayoutContext, + font_cx: &mut FontContext, + styled: &StyledText, + workspace: &mut ParleyStyleRunWorkspace, + scale: f32, + quantize: bool, + push_style: F, +) -> Result, Error> +where + T: Debug + TextStorage, + B: Brush, + F: FnMut(&mut StyleRunBuilder<'_, B>, SegmentStyle<'_, L, P>) -> u16, +{ + let text = styled.as_str().ok_or(Error::NonContiguousText)?; + let mut builder = layout_cx.style_run_builder(font_cx, text, scale, quantize); + push_style_runs(&mut builder, styled, workspace, push_style)?; + Ok(builder.build(text)) +} + +/// Builds a Parley layout from styled text using the default Parley style +/// payloads. +pub fn build_layout_from_parley_styled_text( + layout_cx: &mut LayoutContext, + font_cx: &mut FontContext, + styled: &ParleyStyledText, + workspace: &mut ParleyStyleRunWorkspace, + scale: f32, + quantize: bool, +) -> Result, Error> +where + T: Debug + TextStorage, + B: Brush, +{ + build_layout_from_styled_text( + layout_cx, + font_cx, + styled, + workspace, + scale, + quantize, + push_parley_style, + ) +} + +#[cfg(test)] +mod tests { + use alloc::sync::Arc; + use alloc::vec; + use alloc::vec::Vec; + + use parley::TextStyle; + use styled_text::{StyleSetBuilder, StyledText, StyledTextBuilder}; + + use super::{ + Error, MAX_PARLEY_STYLES, ParleyStyleRunWorkspace, build_layout_from_parley_styled_text, + check_style_count, push_style_runs, + }; + use crate::{ParleyLayoutStyle, ParleyPaintStyle, ParleyStyleChange}; + + #[test] + fn generic_lowering_accepts_custom_style_payloads() { + let mut style_builder = StyleSetBuilder::::new(); + let base = style_builder.intern_style(12, ()); + let large = style_builder.intern_style(24, ()); + let styles = Arc::new(style_builder.finish()); + + let mut styled = StyledText::new("abcd", styles, base); + styled + .apply_style_bytes(1..2, large) + .expect("valid style range"); + styled + .apply_style_bytes(2..3, large) + .expect("valid style range"); + + let mut font_cx = parley::FontContext::new(); + let mut layout_cx = parley::LayoutContext::<()>::new(); + let mut builder = layout_cx.style_run_builder(&mut font_cx, "abcd", 1.0, false); + let mut workspace = ParleyStyleRunWorkspace::new(); + + let mut pushed_font_sizes = Vec::new(); + push_style_runs(&mut builder, &styled, &mut workspace, |builder, style| { + let font_size = f32::from(*style.layout()); + pushed_font_sizes.push(font_size); + let parley_style = TextStyle::<()> { + font_size, + ..TextStyle::default() + }; + builder.push_style(parley_style) + }) + .expect("style count fits Parley"); + + let _layout = builder.build("abcd"); + assert_eq!(pushed_font_sizes, vec![12.0, 24.0]); + } + + #[test] + fn rejects_style_tables_larger_than_parley_can_index() { + let count = MAX_PARLEY_STYLES + 1; + assert_eq!( + check_style_count(count), + Err(Error::TooManyStyles { count }) + ); + } + + #[test] + fn builds_layout_from_default_parley_payloads() { + let mut builder = StyledTextBuilder::<_, _, ParleyStyleChange<()>>::new( + ParleyLayoutStyle::default(), + ParleyPaintStyle::default(), + ); + builder.push_with("abcd", ParleyStyleChange::default().font_size(24.0)); + let styled = builder.finish(); + + let mut font_cx = parley::FontContext::new(); + let mut layout_cx = parley::LayoutContext::<()>::new(); + let mut workspace = ParleyStyleRunWorkspace::new(); + let layout = build_layout_from_parley_styled_text( + &mut layout_cx, + &mut font_cx, + &styled, + &mut workspace, + 1.0, + false, + ) + .expect("string storage is contiguous"); + + assert_eq!(layout.styles().len(), 2); + } +} diff --git a/styled_text_parley/src/style.rs b/styled_text_parley/src/style.rs new file mode 100644 index 00000000..e99e6141 --- /dev/null +++ b/styled_text_parley/src/style.rs @@ -0,0 +1,372 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use parley::{ + Brush, FontFamily, FontFeatures, FontStyle, FontVariations, FontWeight, FontWidth, Language, + LineHeight, OverflowWrap, TextStyle, TextWrapMode, WordBreak, +}; +use styled_text::StylePatch; + +/// Layout-affecting style payload for the default Parley integration. +/// +/// These fields are separated from [`ParleyPaintStyle`] so paint-only changes +/// can share layout payloads and avoid invalidating shaping or line layout. +#[derive(Clone, Debug, PartialEq)] +pub struct ParleyLayoutStyle { + /// CSS `font-family` property value. + pub font_family: FontFamily<'static>, + /// Font size. + pub font_size: f32, + /// Font width. + pub font_width: FontWidth, + /// Font style. + pub font_style: FontStyle, + /// Font weight. + pub font_weight: FontWeight, + /// Font variation settings. + pub font_variations: FontVariations<'static>, + /// Font feature settings. + pub font_features: FontFeatures<'static>, + /// Locale. + pub locale: Option, + /// Line height. + pub line_height: LineHeight, + /// Extra spacing between words. + pub word_spacing: f32, + /// Extra spacing between letters. + pub letter_spacing: f32, + /// Control over where words can wrap. + pub word_break: WordBreak, + /// Control over emergency line breaking. + pub overflow_wrap: OverflowWrap, + /// Control over non-emergency line breaking. + pub text_wrap_mode: TextWrapMode, +} + +impl Default for ParleyLayoutStyle { + fn default() -> Self { + let style = TextStyle::<()>::default(); + Self { + font_family: style.font_family, + font_size: style.font_size, + font_width: style.font_width, + font_style: style.font_style, + font_weight: style.font_weight, + font_variations: style.font_variations, + font_features: style.font_features, + locale: style.locale, + line_height: style.line_height, + word_spacing: style.word_spacing, + letter_spacing: style.letter_spacing, + word_break: style.word_break, + overflow_wrap: style.overflow_wrap, + text_wrap_mode: style.text_wrap_mode, + } + } +} + +/// Paint-only style payload for the default Parley integration. +/// +/// These fields affect rendered glyphs and decorations, but not shaping or line +/// layout. +#[derive(Clone, Debug, PartialEq)] +pub struct ParleyPaintStyle { + /// Brush for rendering text. + pub brush: B, + /// Underline decoration. + pub has_underline: bool, + /// Offset of the underline decoration. + pub underline_offset: Option, + /// Size of the underline decoration. + pub underline_size: Option, + /// Brush for rendering the underline decoration. + pub underline_brush: Option, + /// Strikethrough decoration. + pub has_strikethrough: bool, + /// Offset of the strikethrough decoration. + pub strikethrough_offset: Option, + /// Size of the strikethrough decoration. + pub strikethrough_size: Option, + /// Brush for rendering the strikethrough decoration. + pub strikethrough_brush: Option, +} + +impl ParleyPaintStyle { + /// Creates a paint style with the given text brush and default decorations. + #[must_use] + pub fn new(brush: B) -> Self { + Self { + brush, + ..Self::default() + } + } +} + +impl Default for ParleyPaintStyle { + fn default() -> Self { + let style = TextStyle::::default(); + Self { + brush: style.brush, + has_underline: style.has_underline, + underline_offset: style.underline_offset, + underline_size: style.underline_size, + underline_brush: style.underline_brush, + has_strikethrough: style.has_strikethrough, + strikethrough_offset: style.strikethrough_offset, + strikethrough_size: style.strikethrough_size, + strikethrough_brush: style.strikethrough_brush, + } + } +} + +/// Partial style patch for the default Parley style payloads. +/// +/// Each `Some` field replaces the corresponding field in the current full +/// style. Callers that need inheritance, cascading, or a smaller domain-specific +/// patch type can continue to implement [`StylePatch`] directly. The fluent +/// methods cover common fields; all fields are public for less common changes. +/// +/// Fields whose destination value is itself optional use `Option>`: +/// the outer `None` leaves the current value unchanged, `Some(None)` clears it, +/// and `Some(Some(value))` sets it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ParleyStyleChange { + /// CSS `font-family` property value. + pub font_family: Option>, + /// Font size. + pub font_size: Option, + /// Font width. + pub font_width: Option, + /// Font style. + pub font_style: Option, + /// Font weight. + pub font_weight: Option, + /// Font variation settings. + pub font_variations: Option>, + /// Font feature settings. + pub font_features: Option>, + /// Locale. + /// + /// The outer option controls whether this patch changes the locale; the + /// inner option is the resulting locale value. + pub locale: Option>, + /// Brush for rendering text. + pub brush: Option, + /// Underline decoration. + pub underline: Option, + /// Offset of the underline decoration. + /// + /// The outer option controls whether this patch changes the offset; the + /// inner option is the resulting offset value. + pub underline_offset: Option>, + /// Size of the underline decoration. + /// + /// The outer option controls whether this patch changes the size; the inner + /// option is the resulting size value. + pub underline_size: Option>, + /// Brush for rendering the underline decoration. + /// + /// The outer option controls whether this patch changes the brush; the + /// inner option is the resulting brush value. + pub underline_brush: Option>, + /// Strikethrough decoration. + pub strikethrough: Option, + /// Offset of the strikethrough decoration. + /// + /// The outer option controls whether this patch changes the offset; the + /// inner option is the resulting offset value. + pub strikethrough_offset: Option>, + /// Size of the strikethrough decoration. + /// + /// The outer option controls whether this patch changes the size; the inner + /// option is the resulting size value. + pub strikethrough_size: Option>, + /// Brush for rendering the strikethrough decoration. + /// + /// The outer option controls whether this patch changes the brush; the + /// inner option is the resulting brush value. + pub strikethrough_brush: Option>, + /// Line height. + pub line_height: Option, + /// Extra spacing between words. + pub word_spacing: Option, + /// Extra spacing between letters. + pub letter_spacing: Option, + /// Control over where words can wrap. + pub word_break: Option, + /// Control over emergency line breaking. + pub overflow_wrap: Option, + /// Control over non-emergency line breaking. + pub text_wrap_mode: Option, +} + +impl ParleyStyleChange { + /// Sets font size. + #[must_use] + pub fn font_size(mut self, font_size: f32) -> Self { + self.font_size = Some(font_size); + self + } + + /// Sets font weight. + #[must_use] + pub fn font_weight(mut self, font_weight: FontWeight) -> Self { + self.font_weight = Some(font_weight); + self + } + + /// Sets underline decoration. + #[must_use] + pub fn underline(mut self, enabled: bool) -> Self { + self.underline = Some(enabled); + self + } + + /// Sets strikethrough decoration. + #[must_use] + pub fn strikethrough(mut self, enabled: bool) -> Self { + self.strikethrough = Some(enabled); + self + } + + /// Sets letter spacing. + #[must_use] + pub fn letter_spacing(mut self, letter_spacing: f32) -> Self { + self.letter_spacing = Some(letter_spacing); + self + } + + /// Sets the text brush. + #[must_use] + pub fn brush(mut self, brush: B) -> Self { + self.brush = Some(brush); + self + } +} + +impl StylePatch> for ParleyStyleChange { + fn apply_to(&self, layout: &mut ParleyLayoutStyle, paint: &mut ParleyPaintStyle) { + if let Some(font_family) = &self.font_family { + layout.font_family = font_family.clone(); + } + if let Some(font_size) = self.font_size { + layout.font_size = font_size; + } + if let Some(font_width) = self.font_width { + layout.font_width = font_width; + } + if let Some(font_style) = self.font_style { + layout.font_style = font_style; + } + if let Some(font_weight) = self.font_weight { + layout.font_weight = font_weight; + } + if let Some(font_variations) = &self.font_variations { + layout.font_variations = font_variations.clone(); + } + if let Some(font_features) = &self.font_features { + layout.font_features = font_features.clone(); + } + if let Some(locale) = self.locale { + layout.locale = locale; + } + if let Some(brush) = &self.brush { + paint.brush = brush.clone(); + } + if let Some(underline) = self.underline { + paint.has_underline = underline; + } + if let Some(underline_offset) = self.underline_offset { + paint.underline_offset = underline_offset; + } + if let Some(underline_size) = self.underline_size { + paint.underline_size = underline_size; + } + if let Some(underline_brush) = &self.underline_brush { + paint.underline_brush.clone_from(underline_brush); + } + if let Some(strikethrough) = self.strikethrough { + paint.has_strikethrough = strikethrough; + } + if let Some(strikethrough_offset) = self.strikethrough_offset { + paint.strikethrough_offset = strikethrough_offset; + } + if let Some(strikethrough_size) = self.strikethrough_size { + paint.strikethrough_size = strikethrough_size; + } + if let Some(strikethrough_brush) = &self.strikethrough_brush { + paint.strikethrough_brush.clone_from(strikethrough_brush); + } + if let Some(line_height) = self.line_height { + layout.line_height = line_height; + } + if let Some(word_spacing) = self.word_spacing { + layout.word_spacing = word_spacing; + } + if let Some(letter_spacing) = self.letter_spacing { + layout.letter_spacing = letter_spacing; + } + if let Some(word_break) = self.word_break { + layout.word_break = word_break; + } + if let Some(overflow_wrap) = self.overflow_wrap { + layout.overflow_wrap = overflow_wrap; + } + if let Some(text_wrap_mode) = self.text_wrap_mode { + layout.text_wrap_mode = text_wrap_mode; + } + } +} + +#[cfg(test)] +mod tests { + use alloc::vec::Vec; + + use parley::FontWeight; + use styled_text::{StyledSegmentsWorkspace, StyledTextBuilder}; + + use super::{ParleyLayoutStyle, ParleyPaintStyle, ParleyStyleChange}; + + #[test] + fn parley_style_change_resolves_default_payloads_independently() { + let mut builder = StyledTextBuilder::<_, _, ParleyStyleChange<[u8; 4]>>::new( + ParleyLayoutStyle::default(), + ParleyPaintStyle::new([0, 0, 0, 255]), + ); + let all = builder.push("abcd"); + builder.apply( + all, + ParleyStyleChange::default() + .font_size(24.0) + .font_weight(FontWeight::BOLD), + ); + builder + .apply_bytes(1..3, ParleyStyleChange::default().brush([255, 0, 0, 255])) + .expect("valid range"); + + let styled = builder.finish(); + assert_eq!(styled.style_set().style_len(), 3); + assert_eq!(styled.style_set().layout_len(), 2); + assert_eq!(styled.style_set().paint_len(), 2); + + let mut workspace = StyledSegmentsWorkspace::new(); + let segments = workspace.segments(&styled).collect::>(); + assert_eq!(segments.len(), 3); + + let first = styled + .style_set() + .get_style(segments[0].style()) + .expect("segment style is interned"); + let second = styled + .style_set() + .get_style(segments[1].style()) + .expect("segment style is interned"); + assert_eq!(first.layout_id(), second.layout_id()); + assert_ne!(first.paint_id(), second.paint_id()); + + let style = styled.style_set().segment_style(segments[1].style()); + assert_eq!(style.layout().font_size, 24.0); + assert_eq!(style.layout().font_weight, FontWeight::BOLD); + assert_eq!(style.paint().brush, [255, 0, 0, 255]); + } +}