Compare commits

..
Author SHA1 Message Date
iris-aiandiris 0f6a28b4dd Move text layout and rendering to Parley (#10)
Replace the cosmic-text path with Parley layout and Swash rasterization, backed by shared glyph-atlas pages. Shaping, editing, rasterization, and glyph rendering move together because they share the text buffer and rendered-glyph types; splitting them further would require a temporary renderer that is immediately removed.

This is reconstructed rather than replayed from the extraction history. It also fixes issues found during review:

- texture binding changes remain set when an atlas patch follows a new page
- pressing an empty field places a caret and accepts input
- selection motion delegates collapse behavior to Parley
- character deletion follows logical clusters rather than visual neighbors
- the unused root-level Swash dependency is omitted

Four public-behavior integration tests live in `tests/text_edit.rs`: empty-field input, multibyte IME preedit replacement, UTF-8-safe backspace, and selection replacement. The old twelve-test inline block and implementation-restating cases are omitted.

Every added source comment was manually reviewed. Comments that narrated implementation or history were removed; retained comments document cache/rasterization keys, GPU upload constraints, focus representation, bidi geometry, or IME semantics.

Known limitation: atlas pages currently grow without eviction. Each page is 4 MiB on CPU and GPU. An arbitrary cap would leave cached rendered-text UVs pointing at reused glyph slots, so bounding this safely needs a later generation/invalidation change.

This changes public text types and signatures. GPU glyph rendering is covered by compilation rather than a live-surface test.

Verified with:

- `cargo fmt --all --check`
- `cargo clippy --workspace --all-targets -- -D warnings`
- `cargo test --workspace` (four integration tests pass)

Cargo still reports inherited future-incompatibility notices for existing wgpu/winit dependencies; there are no current clippy warnings.

---------

Co-authored-by: iris <2+iris@noreply.localhost>
Reviewed-on: iris/iris#10
Reviewed-by: iris <2+iris@noreply.localhost>
Co-authored-by: AIris <4+iris-ai@noreply.localhost>
2026-09-13 03:39:23 -04:00
11 changed files with 266 additions and 237 deletions

No files matched your search

Generated
+1
View File
@@ -1189,6 +1189,7 @@ dependencies = [
"bytemuck", "bytemuck",
"fxhash", "fxhash",
"image", "image",
"log",
"parley", "parley",
"swash", "swash",
"wgpu", "wgpu",
+1
View File
@@ -35,6 +35,7 @@ image = "0.25.6"
parley = "0.11.1" parley = "0.11.1"
swash = "0.2.10" swash = "0.2.10"
fxhash = "0.2.1" fxhash = "0.2.1"
log = "0.4.29"
arboard = "3.6.1" arboard = "3.6.1"
iris-core = { path = "core" } iris-core = { path = "core" }
iris-macro = { path = "macro" } iris-macro = { path = "macro" }
+1 -25
View File
@@ -1,19 +1,6 @@
images images
settings (sampler) settings (sampler)
consider typed TextureHandle<T> variants for distinct texture uses
text
figure out ways to speed up / what costs the most
resizing (per frame) is really slow (assuming painter isn't griefing)
j is weird / fix x offset
masks r just made to bare minimum work
scaling
could be just a simple scaling factor that multiplies abs
and need to ensure text uses raw abs and not scaled abs
naming? (pt, px)
want to keep (drawn) regions using px? or should I add another field to UiScalar/Vec
field could be best solution so redrawing stuff isn't needed & you can specify both as user
WidgetRef<W> or smth instead of Id WidgetRef<W> or smth instead of Id
enum that's either an Id or an actual concrete instance of W enum that's either an Id or an actual concrete instance of W
@@ -24,17 +11,6 @@ WidgetRef<W> or smth instead of Id
maybe introduce InnerWidget trait to allow for editors to expose & modify inner type maybe introduce InnerWidget trait to allow for editors to expose & modify inner type
maybe could also store a parent widget and keep using InnerWidget trait? unsure if possible maybe could also store a parent widget and keep using InnerWidget trait? unsure if possible
really weird limitation:
I don't think you can currently remove an element from a parent and put it in a child of the same parent
because it removes the unused children after the entire parent redraw
but the child gets drawn during that, so it will think the child is still active !!!
or something like that idk, maybe I need a special enum for parent that includes a undecided state where it may or may not get redrawn by the parent
or just do ref counting and ensure all drawn things == 1 afterwards (seems like best way)
ok so I'm removing the limit for now
don't forget I'm streaming
tags
vecs for each widget type? vecs for each widget type?
POTENTIAL BUG: closures that store IDs will not decrement the id!!! need to not increment id if moved into closure somehow??? wait no, need to decrement ID every time an event fn is added...... only if the id is used in it..?? POTENTIAL BUG: closures that store IDs will not decrement the id!!! need to not increment id if moved into closure somehow??? wait no, need to decrement ID every time an event fn is added...... only if the id is used in it..??
+1
View File
@@ -10,3 +10,4 @@ image = { workspace = true }
parley = { workspace = true } parley = { workspace = true }
swash = { workspace = true } swash = { workspace = true }
fxhash = { workspace = true } fxhash = { workspace = true }
log = { workspace = true }
+95 -54
View File
@@ -1,8 +1,12 @@
use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiColor, util::Vec2}; use crate::{
Align, GlyphAtlas, GlyphEntry, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiColor,
util::Vec2,
};
use parley::{ use parley::{
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, GenericFamily, Layout, Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, GenericFamily, Layout,
LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
}; };
use std::hash::{DefaultHasher, Hash, Hasher};
use swash::{ use swash::{
FontRef, FontRef,
scale::{Render, ScaleContext, Source, StrikeWith}, scale::{Render, ScaleContext, Source, StrikeWith},
@@ -10,18 +14,18 @@ use swash::{
}; };
pub struct TextData { pub struct TextData {
pub font_cx: FontContext, pub font_ctx: FontContext,
pub layout_cx: LayoutContext<UiColor>, pub layout_ctx: LayoutContext<UiColor>,
scale_cx: ScaleContext, scale_ctx: ScaleContext,
pub atlas: GlyphAtlas, pub atlas: GlyphAtlas,
} }
impl Default for TextData { impl Default for TextData {
fn default() -> Self { fn default() -> Self {
Self { Self {
font_cx: FontContext::new(), font_ctx: FontContext::new(),
layout_cx: LayoutContext::new(), layout_ctx: LayoutContext::new(),
scale_cx: ScaleContext::new(), scale_ctx: ScaleContext::new(),
atlas: GlyphAtlas::default(), atlas: GlyphAtlas::default(),
} }
} }
@@ -77,7 +81,13 @@ impl Default for TextAttrs {
pub struct TextBuffer { pub struct TextBuffer {
text: String, text: String,
layout: Layout<UiColor>, layout: Layout<UiColor>,
shaped: Option<(TextAttrs, Option<f32>)>, layout_key: Option<LayoutKey>,
}
#[derive(PartialEq)]
struct LayoutKey {
attrs: TextAttrs,
max_width: Option<f32>,
} }
impl TextBuffer { impl TextBuffer {
@@ -85,7 +95,7 @@ impl TextBuffer {
Self { Self {
text: text.into(), text: text.into(),
layout: Layout::new(), layout: Layout::new(),
shaped: None, layout_key: None,
} }
} }
@@ -109,13 +119,13 @@ impl TextBuffer {
let text = text.into(); let text = text.into();
if text != self.text { if text != self.text {
self.text = text; self.text = text;
self.shaped = None; self.layout_key = None;
} }
} }
/// Invalidates the layout and returns the underlying string for editing. /// Invalidates the layout and returns the underlying string for editing.
pub fn edit(&mut self) -> &mut String { pub fn edit(&mut self) -> &mut String {
self.shaped = None; self.layout_key = None;
&mut self.text &mut self.text
} }
@@ -124,12 +134,16 @@ impl TextBuffer {
} }
pub fn shape(&mut self, data: &mut TextData, attrs: &TextAttrs, width: Option<f32>) { pub fn shape(&mut self, data: &mut TextData, attrs: &TextAttrs, width: Option<f32>) {
if self.shaped.as_ref() == Some(&(attrs.clone(), width)) { let layout_key = LayoutKey {
attrs: attrs.clone(),
max_width: width,
};
if self.layout_key.as_ref() == Some(&layout_key) {
return; return;
} }
let mut builder = data let mut builder = data
.layout_cx .layout_ctx
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true); .ranged_builder(&mut data.font_ctx, &self.text, 1.0, true);
builder.push_default(StyleProperty::FontFamily(attrs.family.family())); builder.push_default(StyleProperty::FontFamily(attrs.family.family()));
builder.push_default(StyleProperty::FontSize(attrs.font_size)); builder.push_default(StyleProperty::FontSize(attrs.font_size));
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute( builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
@@ -140,7 +154,7 @@ impl TextBuffer {
self.layout.break_all_lines(width); self.layout.break_all_lines(width);
self.layout self.layout
.align(Alignment::Start, AlignmentOptions::default()); .align(Alignment::Start, AlignmentOptions::default());
self.shaped = Some((attrs.clone(), width)); self.layout_key = Some(layout_key);
} }
} }
@@ -160,8 +174,6 @@ impl TextData {
continue; continue;
}; };
let coords_hash = hash_coords(coords); let coords_hash = hash_coords(coords);
// `font.data.id()` rather than the pointer, so the same font
// loaded twice is still one set of entries.
let font_id = font.data.id(); let font_id = font.data.id();
for glyph in run.positioned_glyphs() { for glyph in run.positioned_glyphs() {
@@ -169,38 +181,23 @@ impl TextData {
let key = GlyphKey { let key = GlyphKey {
font: font_id, font: font_id,
glyph: glyph.id, glyph: glyph.id,
size: (font_size * 16.0).round() as u32, size: glyph_size_key(font_size),
subpixel, subpixel,
coords: coords_hash, coords: coords_hash,
}; };
let entry = match self.atlas.get(&key) { let Some(entry) = self.glyph_entry(
Some(entry) => entry, GlyphRaster {
None => { key,
let mut scaler = self font: font_ref,
.scale_cx font_size,
.builder(font_ref) coords,
.size(font_size) subpixel,
.hint(true) glyph_id: glyph.id,
.normalized_coords(coords) },
.build(); textures,
let image = Render::new(&[ ) else {
Source::ColorOutline(0), continue;
Source::ColorBitmap(StrikeWith::BestFit),
Source::Outline,
])
.format(Format::Alpha)
.offset(Vector::new(subpixel as f32 / 4.0, 0.0))
.render(&mut scaler, glyph.id as u16);
match image {
Some(image) => self.atlas.insert(key, &image, textures),
None => {
self.atlas.insert_empty(key);
None
}
}
}
}; };
let Some(entry) = entry else { continue };
placed.push(PlacedGlyph { placed.push(PlacedGlyph {
entry, entry,
offset: Vec2::new( offset: Vec2::new(
@@ -213,20 +210,64 @@ impl TextData {
} }
placed placed
} }
fn glyph_entry(
&mut self,
glyph: GlyphRaster<'_>,
textures: &mut Textures,
) -> Option<GlyphEntry> {
if let Some(entry) = self.atlas.get(&glyph.key) {
return entry;
}
let mut scaler = self
.scale_ctx
.builder(glyph.font)
.size(glyph.font_size)
.hint(true)
.normalized_coords(glyph.coords)
.build();
let image = Render::new(&[
Source::ColorOutline(0),
Source::ColorBitmap(StrikeWith::BestFit),
Source::Outline,
])
.format(Format::Alpha)
.offset(Vector::new(glyph.subpixel as f32 / 4.0, 0.0))
.render(&mut scaler, glyph.glyph_id as u16);
if let Some(image) = image {
self.atlas.insert(glyph.key, &image, textures)
} else {
self.atlas.insert_empty(glyph.key);
None
}
}
}
struct GlyphRaster<'a> {
key: GlyphKey,
font: FontRef<'a>,
font_size: f32,
coords: &'a [i16],
subpixel: u8,
glyph_id: u32,
} }
fn hash_coords(coords: &[i16]) -> u64 { fn hash_coords(coords: &[i16]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325; let mut hasher = DefaultHasher::new();
for c in coords { coords.hash(&mut hasher);
h ^= *c as u16 as u64; hasher.finish()
h = h.wrapping_mul(0x1000_0000_01b3); }
}
h const GLYPH_SIZE_STEPS_PER_PIXEL: f32 = 16.0;
fn glyph_size_key(font_size: f32) -> u32 {
(font_size * GLYPH_SIZE_STEPS_PER_PIXEL).round() as u32
} }
#[derive(Clone)]
pub struct RenderedText { pub struct RenderedText {
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>, pub glyphs: Vec<PlacedGlyph>,
pub size: Vec2, pub size: Vec2,
pub color: UiColor, pub color: UiColor,
} }
@@ -242,7 +283,7 @@ impl TextData {
buffer.shape(self, attrs, width); buffer.shape(self, attrs, width);
let glyphs = self.place(buffer, textures); let glyphs = self.place(buffer, textures);
RenderedText { RenderedText {
glyphs: std::sync::Arc::new(glyphs), glyphs,
size: buffer.size(), size: buffer.size(),
color: attrs.color, color: attrs.color,
} }
+84 -52
View File
@@ -27,18 +27,26 @@ pub struct GlyphKey {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct GlyphEntry { pub struct GlyphEntry {
pub uv_min: [f32; 2], pub uv_min: Vec2,
pub uv_max: [f32; 2], pub uv_max: Vec2,
/// Offset from the glyph's pen position to the top-left of its pixels. /// Offset from the glyph's pen position to the top-left of its pixels.
pub left: i32, pub left: i32,
pub top: i32, pub top: i32,
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
pub is_color: bool, pub is_colored: bool,
pub view_idx: u32, pub view_idx: u32,
pub sampler_idx: u32, pub sampler_idx: u32,
} }
impl GlyphEntry {
const IS_COLORED: u32 = 1;
pub(crate) fn flags(&self) -> u32 {
if self.is_colored { Self::IS_COLORED } else { 0 }
}
}
struct Page { struct Page {
handle: TextureHandle, handle: TextureHandle,
x: u32, x: u32,
@@ -68,10 +76,20 @@ impl GlyphAtlas {
let w = image.placement.width; let w = image.placement.width;
let h = image.placement.height; let h = image.placement.height;
if w == 0 || h == 0 { if w == 0 || h == 0 {
log::warn!(
"glyph {} in font {} rasterized at {w}x{h}; skipping it",
key.glyph,
key.font,
);
self.entries.insert(key, None); self.entries.insert(key, None);
return None; return None;
} }
if w + PAD * 2 > PAGE || h + PAD * 2 > PAGE { if w > PAGE - PAD * 2 || h > PAGE - PAD * 2 {
log::warn!(
"glyph {} in font {} rasterized at {w}x{h}, too large for the {PAGE}x{PAGE} atlas; skipping it",
key.glyph,
key.font,
);
self.entries.insert(key, None); self.entries.insert(key, None);
return None; return None;
} }
@@ -95,13 +113,13 @@ impl GlyphAtlas {
let page = &self.pages[page_idx]; let page = &self.pages[page_idx];
let scale = 1.0 / PAGE as f32; let scale = 1.0 / PAGE as f32;
let entry = GlyphEntry { let entry = GlyphEntry {
uv_min: [x as f32 * scale, y as f32 * scale], uv_min: Vec2::new(x as f32 * scale, y as f32 * scale),
uv_max: [(x + w) as f32 * scale, (y + h) as f32 * scale], uv_max: Vec2::new((x + w) as f32 * scale, (y + h) as f32 * scale),
left: image.placement.left, left: image.placement.left,
top: image.placement.top, top: image.placement.top,
width: w, width: w,
height: h, height: h,
is_color: matches!(image.content, Content::Color), is_colored: matches!(image.content, Content::Color),
view_idx: page.handle.primitive().view_idx, view_idx: page.handle.primitive().view_idx,
sampler_idx: page.handle.primitive().sampler_idx, sampler_idx: page.handle.primitive().sampler_idx,
}; };
@@ -110,18 +128,12 @@ impl GlyphAtlas {
} }
fn allocate(&mut self, w: u32, h: u32, textures: &mut Textures) -> (usize, u32, u32) { fn allocate(&mut self, w: u32, h: u32, textures: &mut Textures) -> (usize, u32, u32) {
let need_w = w + PAD; if let Some((i, (x, y))) = self
let need_h = h + PAD; .pages
if let Some(i) = self.pages.iter().position(|p| fits(p, need_w, need_h)) { .iter_mut()
let page = &mut self.pages[i]; .enumerate()
if page.x + need_w > PAGE { .find_map(|(i, page)| page.allocate(w, h).map(|position| (i, position)))
page.y += page.shelf_height; {
page.x = PAD;
page.shelf_height = 0;
}
let (x, y) = (page.x, page.y);
page.x += need_w;
page.shelf_height = page.shelf_height.max(need_h);
return (i, x, y); return (i, x, y);
} }
@@ -148,45 +160,65 @@ impl GlyphAtlas {
} }
} }
fn fits(page: &Page, need_w: u32, need_h: u32) -> bool { impl Page {
(page.x + need_w <= PAGE && page.y + need_h <= PAGE) fn allocate(&mut self, w: u32, h: u32) -> Option<(u32, u32)> {
|| (need_w + PAD <= PAGE && page.y + page.shelf_height + need_h <= PAGE) let need_w = w + PAD;
let need_h = h + PAD;
if self.x + need_w > PAGE {
if need_w + PAD > PAGE || self.y + self.shelf_height + need_h > PAGE {
return None;
}
self.y += self.shelf_height;
self.x = PAD;
self.shelf_height = 0;
} else if self.y + need_h > PAGE {
return None;
}
let position = (self.x, self.y);
self.x += need_w;
self.shelf_height = self.shelf_height.max(need_h);
Some(position)
}
} }
/// Mask glyphs keep coverage in alpha so their raster can be tinted at draw time. /// Mask glyphs keep coverage in alpha so their raster can be tinted at draw time.
fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) { fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) {
let w = image.placement.width; let width = image.placement.width as usize;
let h = image.placement.height; let height = image.placement.height as usize;
match image.content { let page_stride = page.width() as usize * 4;
Content::Mask => { let x = x as usize * 4;
for row in 0..h { let y = y as usize;
for col in 0..w { let page = page.as_mut();
let a = image.data[(row * w + col) as usize];
page.put_pixel(x + col, y + row, image::Rgba([255, 255, 255, a])); for row in 0..height {
let start = (y + row) * page_stride + x;
let target = &mut page[start..start + width * 4];
match image.content {
Content::Color => {
let start = row * width * 4;
target.copy_from_slice(&image.data[start..start + width * 4]);
}
Content::Mask => {
let start = row * width;
for (target, &alpha) in target
.as_chunks_mut::<4>()
.0
.iter_mut()
.zip(&image.data[start..start + width])
{
target.copy_from_slice(&[255, 255, 255, alpha]);
} }
} }
} Content::SubpixelMask => {
Content::Color => { let start = row * width * 4;
for row in 0..h { for (target, source) in target
for col in 0..w { .as_chunks_mut::<4>()
let i = ((row * w + col) * 4) as usize; .0
let px = [ .iter_mut()
image.data[i], .zip(image.data[start..start + width * 4].as_chunks::<4>().0)
image.data[i + 1], {
image.data[i + 2], target.copy_from_slice(&[255, 255, 255, source[1]]);
image.data[i + 3],
];
page.put_pixel(x + col, y + row, image::Rgba(px));
}
}
}
Content::SubpixelMask => {
// Preserve readable output if the rasterizer returns a subpixel mask.
for row in 0..h {
for col in 0..w {
let i = ((row * w + col) * 4) as usize;
let a = image.data[i + 1];
page.put_pixel(x + col, y + row, image::Rgba([255, 255, 255, a]));
} }
} }
} }
+3 -6
View File
@@ -6,6 +6,7 @@ use crate::{
ArrBuf, ArrBuf,
data::{MaskIdx, PrimitiveInstance}, data::{MaskIdx, PrimitiveInstance},
}, },
util::Vec2,
}; };
use bytemuck::Pod; use bytemuck::Pod;
use wgpu::*; use wgpu::*;
@@ -234,18 +235,14 @@ pub struct TexturePrimitive {
#[repr(C)] #[repr(C)]
#[derive(Debug, Copy, Clone)] #[derive(Debug, Copy, Clone)]
pub struct GlyphPrimitive { pub struct GlyphPrimitive {
pub uv_min: [f32; 2], pub uv_min: Vec2,
pub uv_max: [f32; 2], pub uv_max: Vec2,
pub view_idx: u32, pub view_idx: u32,
pub sampler_idx: u32, pub sampler_idx: u32,
pub color: Color<u8>, pub color: Color<u8>,
pub flags: u32, pub flags: u32,
} }
impl GlyphPrimitive {
pub const IS_COLOR: u32 = 1;
}
pub struct PrimitiveVec<T> { pub struct PrimitiveVec<T> {
vec: Vec<T>, vec: Vec<T>,
free: Vec<usize>, free: Vec<usize>,
+10 -10
View File
@@ -17,36 +17,36 @@ pub struct GpuTextures {
impl GpuTextures { impl GpuTextures {
pub fn update(&mut self, textures: &mut Textures) -> bool { pub fn update(&mut self, textures: &mut Textures) -> bool {
let mut changed = false; let mut bindings_changed = false;
for update in textures.updates() { for update in textures.updates() {
match update { bindings_changed |= match update {
TextureUpdate::Push(image) => { TextureUpdate::Push(image) => {
self.push(image); self.push(image);
changed = true; true
} }
TextureUpdate::Set(i, image) => { TextureUpdate::Set(i, image) => {
self.set(i, image); self.set(i, image);
changed = true; true
} }
TextureUpdate::Patch(i, rect, image) => { TextureUpdate::Patch(i, rect, image) => {
// Patching contents leaves the binding array unchanged.
self.patch(i, rect, image); self.patch(i, rect, image);
false
} }
TextureUpdate::SetFree => { TextureUpdate::SetFree => {
self.view_count += 1; self.view_count += 1;
changed = true; true
} }
TextureUpdate::Free(i) => { TextureUpdate::Free(i) => {
self.free(i); self.free(i);
changed = true; true
} }
TextureUpdate::PushFree => { TextureUpdate::PushFree => {
self.push_free(); self.push_free();
changed = true; true
} }
} };
} }
changed bindings_changed
} }
fn set(&mut self, i: u32, image: &DynamicImage) { fn set(&mut self, i: u32, image: &DynamicImage) {
self.view_count += 1; self.view_count += 1;
+1 -8
View File
@@ -101,13 +101,6 @@ impl<'a> Painter<'a> {
} }
pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) { pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) {
let flags_for = |is_color| {
if is_color {
GlyphPrimitive::IS_COLOR
} else {
0
}
};
for glyph in text.glyphs.iter() { for glyph in text.glyphs.iter() {
let mut region = origin; let mut region = origin;
region.x.end = region.x.start; region.x.end = region.x.start;
@@ -122,7 +115,7 @@ impl<'a> Painter<'a> {
view_idx: glyph.entry.view_idx, view_idx: glyph.entry.view_idx,
sampler_idx: glyph.entry.sampler_idx, sampler_idx: glyph.entry.sampler_idx,
color: text.color, color: text.color,
flags: flags_for(glyph.entry.is_color), flags: glyph.entry.flags(),
}, },
region, region,
); );
+47 -57
View File
@@ -111,7 +111,7 @@ impl<'a> TextEditCtx<'a> {
self.text.view.buf.layout() self.text.view.buf.layout()
} }
fn refresh(&mut self) { fn clamp_selection_to_layout(&mut self) {
if let Some(sel) = self.text.selection { if let Some(sel) = self.text.selection {
let layout = self.layout(); let layout = self.layout();
self.text.selection = Some(sel.refresh(layout)); self.text.selection = Some(sel.refresh(layout));
@@ -119,8 +119,8 @@ impl<'a> TextEditCtx<'a> {
} }
pub fn take(&mut self) -> String { pub fn take(&mut self) -> String {
let text = self.text.view.buf.text().to_string(); let text = std::mem::take(self.text.view.buf.edit());
self.set(""); self.text.selection = None;
text text
} }
@@ -219,23 +219,21 @@ impl<'a> TextEditCtx<'a> {
if end == 0 { if end == 0 {
return; return;
} }
let start = { let layout = self.layout();
let layout = self.layout(); let start = if word {
if word { sel.focus().previous_logical_word(layout).index()
sel.focus().previous_logical_word(layout).index() } else {
let Some(cluster) = sel.focus().logical_clusters(layout)[0] else {
return;
};
let range = cluster.text_range();
if cluster.is_hard_line_break() || cluster.is_emoji() {
range.start
} else { } else {
let Some(cluster) = sel.focus().logical_clusters(layout)[0] else { self.text.view.buf.text()[..range.end]
return; .char_indices()
}; .next_back()
let range = cluster.text_range(); .map_or(range.start, |(start, _)| start)
if cluster.is_hard_line_break() || cluster.is_emoji() {
range.start
} else {
self.text.view.buf.text()[..range.end]
.char_indices()
.next_back()
.map_or(range.start, |(start, _)| start)
}
} }
}; };
self.delete_range(start, end); self.delete_range(start, end);
@@ -252,27 +250,20 @@ impl<'a> TextEditCtx<'a> {
if start >= self.text.view.buf.text().len() { if start >= self.text.view.buf.text().len() {
return; return;
} }
let end = { let layout = self.layout();
let layout = self.layout(); let end = if word {
if word { sel.focus().next_logical_word(layout).index()
sel.focus().next_logical_word(layout).index() } else {
} else { let clusters = sel.focus().logical_clusters(layout);
let clusters = sel.focus().logical_clusters(layout); let Some(cluster) = clusters[1].as_ref() else {
let Some(cluster) = clusters[1].as_ref() else { return;
return; };
}; cluster.text_range().end
cluster.text_range().end
}
}; };
self.delete_range(start, end); self.delete_range(start, end);
} }
fn delete_range(&mut self, start: usize, end: usize) { fn delete_range(&mut self, start: usize, end: usize) {
let len = self.text.view.buf.text().len();
let (start, end) = (start.min(end).min(len), start.max(end).min(len));
if start == end {
return;
}
self.text.view.buf.edit().replace_range(start..end, ""); self.text.view.buf.edit().replace_range(start..end, "");
self.text.view.buf.changed = true; self.text.view.buf.changed = true;
self.set_caret(start); self.set_caret(start);
@@ -294,31 +285,30 @@ impl<'a> TextEditCtx<'a> {
let prev_sel = self.text.selection; let prev_sel = self.text.selection;
let prev_hit = self.text.double_hit; let prev_hit = self.text.double_hit;
let outcome = { let layout = self.layout();
let layout = self.layout(); let (selection, double_hit) = if drag {
if drag { let Some(selection) = prev_sel else {
prev_sel.map(|sel| (Some(sel.extend_to_point(layout, pos.x, pos.y)), prev_hit)) return;
};
(selection.extend_to_point(layout, pos.x, pos.y), prev_hit)
} else {
let hit = Selection::from_point(layout, pos.x, pos.y);
let index = hit.focus().index();
// Successive clicks at one index select the word, then the line.
if recent && prev_hit == Some(index) {
(Selection::line_from_point(layout, pos.x, pos.y), None)
} else if recent && prev_sel.map(|s| s.focus().index()) == Some(index) {
(
Selection::word_from_point(layout, pos.x, pos.y),
Some(index),
)
} else { } else {
let hit = Selection::from_point(layout, pos.x, pos.y); (hit, None)
let index = hit.focus().index();
// Successive clicks at one index select the word, then the line.
Some(if recent && prev_hit == Some(index) {
(Some(Selection::line_from_point(layout, pos.x, pos.y)), None)
} else if recent && prev_sel.map(|s| s.focus().index()) == Some(index) {
(
Some(Selection::word_from_point(layout, pos.x, pos.y)),
Some(index),
)
} else {
(Some(hit), None)
})
} }
}; };
if let Some((selection, double_hit)) = outcome { self.text.selection = Some(selection);
self.text.selection = selection; self.text.double_hit = double_hit;
self.text.double_hit = double_hit;
}
} }
pub fn deselect(&mut self) { pub fn deselect(&mut self) {
@@ -334,7 +324,7 @@ impl<'a> TextEditCtx<'a> {
if let Some((old, selection)) = self.text.history.pop() { if let Some((old, selection)) = self.text.history.pop() {
self.set(&old); self.set(&old);
self.text.selection = selection; self.text.selection = selection;
self.refresh(); self.clamp_selection_to_layout();
} }
} else if self.text.view.buf.text() != old.0 { } else if self.text.view.buf.text() != old.0 {
self.text.history.push(old); self.text.history.push(old);
+22 -25
View File
@@ -23,7 +23,7 @@ pub struct TextView {
} }
impl TextView { impl TextView {
fn is_blank(&self) -> bool { fn is_empty(&self) -> bool {
self.buf.is_empty() self.buf.is_empty()
} }
@@ -52,31 +52,25 @@ impl TextView {
.align(self.align) .align(self.align)
} }
fn render(&mut self, ctx: &mut SizeCtx) -> RenderedText { fn render(&mut self, ctx: &mut SizeCtx) -> &RenderedText {
let width = if self.attrs.wrap { let width = if self.attrs.wrap {
Some(ctx.px_size().x) Some(ctx.px_size().x)
} else { } else {
None None
}; };
if width == self.width if width != self.width || self.tex.is_none() || self.attrs.changed || self.buf.changed {
&& let Some(tex) = &self.tex self.width = width;
&& !self.attrs.changed self.tex = Some(ctx.draw_text(&mut self.buf, &self.attrs, width));
&& !self.buf.changed self.attrs.changed = false;
{ self.buf.changed = false;
return tex.clone();
} }
self.width = width; self.tex.as_ref().unwrap()
let tex = ctx.draw_text(&mut self.buf, &self.attrs, width);
self.tex = Some(tex.clone());
self.attrs.changed = false;
self.buf.changed = false;
tex
} }
pub fn tex(&self) -> Option<&RenderedText> { pub fn tex(&self) -> Option<&RenderedText> {
self.tex.as_ref() self.tex.as_ref()
} }
pub fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { pub fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
if self.is_blank() if self.is_empty()
&& let Some(hint) = &self.hint && let Some(hint) = &self.hint
{ {
ctx.width(hint) ctx.width(hint)
@@ -85,7 +79,7 @@ impl TextView {
} }
} }
pub fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { pub fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
if self.is_blank() if self.is_empty()
&& let Some(hint) = &self.hint && let Some(hint) = &self.hint
{ {
ctx.height(hint) ctx.height(hint)
@@ -94,16 +88,19 @@ impl TextView {
} }
} }
pub fn draw(&mut self, painter: &mut Painter) -> UiRegion { pub fn draw(&mut self, painter: &mut Painter) -> UiRegion {
let tex = self.render(&mut painter.size_ctx()); let align = self.align;
let region = tex.size.align(self.align); if self.is_empty() && self.hint.is_some() {
if self.is_blank() let region = self.render(&mut painter.size_ctx()).size.align(align);
&& let Some(hint) = &self.hint if let Some(hint) = &self.hint {
{ painter.widget(hint);
painter.widget(hint); }
} else { return region;
let within = region.within(&painter.region());
painter.glyphs(&tex, within);
} }
let tex = self.render(&mut painter.size_ctx());
let region = tex.size.align(align);
let within = region.within(&painter.region());
painter.glyphs(tex, within);
region region
} }