From 51719ed1218a935164d61e773ece59dee987d407 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 12 Sep 2026 14:18:12 -0400 Subject: [PATCH] iris: move text state into shared resources --- benches/message_list.rs | 11 +- core/src/primitive/text.rs | 313 ++++++++++++++++++++++++++++++++--- core/src/ui/mod.rs | 31 +++- core/src/ui/painter.rs | 26 ++- core/src/util/slot.rs | 4 + examples/tabs/lib.rs | 6 +- readme.md | 2 +- src/android/ime.rs | 59 +++---- src/android/input.rs | 2 +- src/android/view.rs | 16 +- src/desktop/mod.rs | 6 +- src/layout_tests.rs | 8 +- src/rsc/attr.rs | 28 ++-- src/widget/text/build.rs | 16 +- src/widget/text/edit.rs | 280 +++++++++++++++---------------- src/widget/text/mod.rs | 165 +++++++----------- src/widget/text/selection.rs | 92 +++++----- 17 files changed, 631 insertions(+), 434 deletions(-) diff --git a/benches/message_list.rs b/benches/message_list.rs index 5898519..92e76eb 100644 --- a/benches/message_list.rs +++ b/benches/message_list.rs @@ -21,9 +21,10 @@ exists only to give a row enough text to wrap across several lines at a \ typical phone column width."; fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget { - let mut text = Text::new(format!("Message {i}: {BODY}")); - text.wrap = true; - let text = rsc.ui.widgets.add_strong(text).any(); + let text = wtext(format!("Message {i}: {BODY}")) + .wrap(true) + .add_strong(rsc) + .any(); if image_every > 0 && i.is_multiple_of(image_every) { let img = image::DynamicImage::new_rgba8(64, 64); @@ -321,9 +322,7 @@ fn bench_redraw_big_text(chars: usize, redraws: usize) { let content: String = (0..chars) .map(|i| char::from(b'a' + (i % 26) as u8)) .collect(); - let mut text = Text::new(content); - text.wrap = true; - let text = rsc.ui.widgets.add_strong(text); + let text = wtext(content).wrap(true).add_strong(&mut rsc); let handle = text.weak(); let root = text.any(); diff --git a/core/src/primitive/text.rs b/core/src/primitive/text.rs index 1f45f85..9ceca49 100644 --- a/core/src/primitive/text.rs +++ b/core/src/primitive/text.rs @@ -1,10 +1,23 @@ -use crate::{Align, GlyphAtlas, GlyphKey, PaintId, PlacedGlyph, RegionAlign, Textures, util::Vec2}; +use crate::{ + Align, GlyphAtlas, GlyphKey, PaintId, PlacedGlyph, RegionAlign, Textures, WidgetId, + util::{SlotId, SlotVec, Vec2}, +}; use parley::{ Alignment, AlignmentOptions, FontContext, FontFamily, FontStyle, FontWeight, GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty, fontique::{Blob, FontInfoOverride}, }; -use std::{collections::HashMap, fmt, ops::Range, sync::Arc}; +use std::{ + cell::{Ref, RefCell}, + collections::HashMap, + fmt, + ops::{Deref, DerefMut, Range}, + rc::Rc, + sync::{ + Arc, + mpsc::{Receiver, Sender, channel}, + }, +}; use swash::{ FontRef, scale::{Render, ScaleContext, Source, StrikeWith}, @@ -25,7 +38,6 @@ pub struct FontDiagnostics { #[derive(Clone, Debug, PartialEq, Eq)] pub enum FontRegistrationError { AlreadyRegistered(String), - TextAlreadyShaped, InvalidFont(String), } @@ -35,10 +47,6 @@ impl fmt::Display for FontRegistrationError { Self::AlreadyRegistered(family) => { write!(f, "font data is already registered for {family:?}") } - Self::TextAlreadyShaped => write!( - f, - "cannot register font data after text has been shaped; register application fonts before the first draw" - ), Self::InvalidFont(family) => { write!( f, @@ -57,10 +65,9 @@ pub struct TextData { scale_cx: ScaleContext, pub atlas: GlyphAtlas, /// Physical pixels per dp -- a second copy of - /// `UiRenderState::density`, kept here too because `TextEditCtx::layout` - /// (cursor movement and hit-testing, `widget/text/edit.rs`) shapes text - /// from an event callback that has a `TextData` but no `Painter`, so it - /// has nowhere else to read the display's density from. Both copies are + /// `UiRenderState::density`, kept here too because cursor movement and + /// hit-testing shape text from event callbacks that have no `Painter`, + /// so they have nowhere else to read the display's density from. Both copies are /// set together, from the one place either backend learns the real /// value (`android::view::new_peer`); this is the same accepted /// duplication as `AndroidRenderer::content_scale`; a single source of @@ -69,7 +76,6 @@ pub struct TextData { pub density: f32, registered_families: HashMap, next_registered_family: u64, - shaping_started: bool, } impl Default for TextData { @@ -84,7 +90,6 @@ impl Default for TextData { density: 1.0, registered_families: HashMap::new(), next_registered_family: 0, - shaping_started: false, } } } @@ -168,9 +173,6 @@ impl TextData { data: impl AsRef<[u8]> + Send + Sync + 'static, ) -> Result<(), FontRegistrationError> { let family = family.as_ref().to_owned(); - if self.shaping_started { - return Err(FontRegistrationError::TextAlreadyShaped); - } if self.registered_families.contains_key(&family) { return Err(FontRegistrationError::AlreadyRegistered(family)); } @@ -523,7 +525,6 @@ impl TextBuffer { width: Option, density: f32, ) { - data.shaping_started = true; if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) { return; } @@ -574,6 +575,10 @@ impl TextBuffer { .align(Alignment::Start, AlignmentOptions::default()); self.shaped = Some((attrs.clone(), width, density)); } + + fn invalidate(&mut self) { + self.shaped = None; + } } fn hash_coords(coords: &[i16]) -> u64 { @@ -605,6 +610,261 @@ pub struct RenderedText { pub generation: u64, } +pub struct TextResource { + buffer: TextBuffer, + attrs: TextAttrs, + rendered: Option, + width: Option, + density: f32, + owner: Option, +} + +impl TextResource { + fn new(buffer: TextBuffer, attrs: TextAttrs) -> Self { + Self { + buffer, + attrs, + rendered: None, + width: None, + density: 0.0, + owner: None, + } + } + + fn invalidate(&mut self) { + self.buffer.invalidate(); + self.rendered = None; + } +} + +/// All text storage and shaping state for one [`crate::Ui`]. Widgets retain a +/// compact [`TextHandle`] into this arena rather than owning Parley layouts. +pub struct TextResources { + data: TextData, + entries: SlotVec, + send: Sender, + recv: Receiver, +} + +impl TextResources { + pub fn new() -> Self { + let (send, recv) = channel(); + Self { + data: TextData::default(), + entries: SlotVec::new(), + send, + recv, + } + } + + pub fn add(resources: Rc>, buffer: TextBuffer, attrs: TextAttrs) -> TextHandle { + let (id, send) = { + let mut resources = resources.borrow_mut(); + resources.free_released(); + let id = resources.entries.add(TextResource::new(buffer, attrs)); + (id, resources.send.clone()) + }; + TextHandle { + id, + resources, + send, + } + } + + fn entry(&self, id: SlotId) -> &TextResource { + self.entries + .get(id) + .expect("text resource handle points at a released slot") + } + + fn entry_mut(&mut self, id: SlotId) -> &mut TextResource { + self.entries + .get_mut(id) + .expect("text resource handle points at a released slot") + } + + pub fn free_released(&mut self) { + for id in self.recv.try_iter() { + self.entries.free(id); + } + } + + pub fn invalidate_all(&mut self) -> Vec { + let mut owners = Vec::new(); + for resource in self.entries.values_mut() { + resource.invalidate(); + if let Some(owner) = resource.owner + && !owners.contains(&owner) + { + owners.push(owner); + } + } + owners + } + + fn shape(&mut self, id: SlotId) { + let density = self.data.density; + let Self { data, entries, .. } = self; + let resource = entries + .get_mut(id) + .expect("text resource handle points at a released slot"); + resource + .buffer + .shape(data, &resource.attrs, resource.width, density); + } + + fn render( + &mut self, + id: SlotId, + width: Option, + owner: WidgetId, + textures: &mut Textures, + density: f32, + ) -> (RenderedText, bool) { + let atlas_generation = self.data.atlas.generation(); + let resource = self.entry_mut(id); + resource.owner = Some(owner); + if resource.width == width + && resource.density == density + && let Some(rendered) = &resource.rendered + && rendered.generation == atlas_generation + { + return (rendered.clone(), false); + } + resource.width = width; + resource.density = density; + let Self { data, entries, .. } = self; + let resource = entries + .get_mut(id) + .expect("text resource handle points at a released slot"); + let rendered = data.render( + &mut resource.buffer, + &resource.attrs, + width, + textures, + density, + ); + resource.rendered = Some(rendered.clone()); + (rendered, true) + } +} + +impl Default for TextResources { + fn default() -> Self { + Self::new() + } +} + +impl Deref for TextResources { + type Target = TextData; + + fn deref(&self) -> &Self::Target { + &self.data + } +} + +impl DerefMut for TextResources { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.data + } +} + +/// A widget-owned reference to one entry in [`TextResources`]. The arena is +/// shared once per UI; constructing a handle does not allocate a resource of +/// its own. +pub struct TextHandle { + id: SlotId, + resources: Rc>, + send: Sender, +} + +impl TextHandle { + pub fn text(&self) -> Ref<'_, str> { + Ref::map(self.resources.borrow(), |resources| { + resources.entry(self.id).buffer.text() + }) + } + + pub fn attrs(&self) -> Ref<'_, TextAttrs> { + Ref::map(self.resources.borrow(), |resources| { + &resources.entry(self.id).attrs + }) + } + + pub fn set_text(&mut self, text: impl Into) -> bool { + let text = text.into(); + let mut resources = self.resources.borrow_mut(); + let resource = resources.entry_mut(self.id); + if resource.buffer.text() == text { + return false; + } + resource.buffer.set_text(text); + resource.rendered = None; + true + } + + pub fn edit_text(&mut self, edit: impl FnOnce(&mut String) -> R) -> R { + let mut resources = self.resources.borrow_mut(); + let resource = resources.entry_mut(self.id); + resource.rendered = None; + edit(resource.buffer.edit()) + } + + pub fn set_spans(&mut self, spans: Vec) { + let mut resources = self.resources.borrow_mut(); + let resource = resources.entry_mut(self.id); + resource.buffer.set_spans(spans); + resource.rendered = None; + } + + pub fn update_attrs(&mut self, update: impl FnOnce(&mut TextAttrs) -> R) -> R { + let mut resources = self.resources.borrow_mut(); + let resource = resources.entry_mut(self.id); + resource.invalidate(); + update(&mut resource.attrs) + } + + pub fn rendered(&self) -> Option { + self.resources.borrow().entry(self.id).rendered.clone() + } + + pub fn width(&self) -> Option { + self.resources.borrow().entry(self.id).width + } + + pub fn with_layout(&self, f: impl FnOnce(&Layout, &str) -> R) -> R { + let mut resources = self.resources.borrow_mut(); + resources.shape(self.id); + let resource = resources.entry(self.id); + f(resource.buffer.layout(), resource.buffer.text()) + } + + pub fn layout(&self) -> Ref<'_, Layout> { + self.resources.borrow_mut().shape(self.id); + Ref::map(self.resources.borrow(), |resources| { + resources.entry(self.id).buffer.layout() + }) + } + + pub fn render( + &self, + width: Option, + owner: WidgetId, + textures: &mut Textures, + density: f32, + ) -> (RenderedText, bool) { + self.resources + .borrow_mut() + .render(self.id, width, owner, textures, density) + } +} + +impl Drop for TextHandle { + fn drop(&mut self) { + let _ = self.send.send(self.id); + } +} + #[cfg(test)] mod tests { use super::*; @@ -619,13 +879,18 @@ mod tests { } #[test] - fn registering_after_shaping_is_reported() { - let mut data = TextData::default(); - let mut buffer = TextBuffer::new("ordinary platform text"); - buffer.shape(&mut data, &TextAttrs::default(), None, 1.0); - assert_eq!( - data.register_font("icons", b"not a font" as &'static [u8]), - Err(FontRegistrationError::TextAlreadyShaped) + fn dropped_handles_release_their_arena_entries() { + let resources = Rc::new(RefCell::new(TextResources::new())); + let text = TextResources::add( + resources.clone(), + TextBuffer::new("temporary"), + TextAttrs::default(), ); + assert_eq!(resources.borrow().entries.len(), 1); + + drop(text); + resources.borrow_mut().free_released(); + + assert!(resources.borrow().entries.is_empty()); } } diff --git a/core/src/ui/mod.rs b/core/src/ui/mod.rs index b4bead8..997e96d 100644 --- a/core/src/ui/mod.rs +++ b/core/src/ui/mod.rs @@ -1,5 +1,6 @@ use crate::{ - Mask, MoveOffset, Paints, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena, + Mask, MoveOffset, Paints, TextResources, Textures, WeakWidget, WidgetId, Widgets, + util::TrackedArena, }; use std::{ cell::{Ref, RefCell, RefMut}, @@ -22,7 +23,7 @@ pub struct UiData { pub widgets: Widgets, pub paints: Paints, pub textures: Textures, - pub text: TextData, + pub text: Rc>, pub masks: TrackedArena, pub move_offsets: TrackedArena, animating: Vec, @@ -67,21 +68,32 @@ pub struct Ui { impl Ui { /// Register application-owned font data for a semantic or named family. - /// - /// This must happen before the first text shape. Existing text layouts - /// cache their resolved faces, so accepting a later registration would - /// leave already-shaped widgets displaying the old result. + /// Existing text resources are invalidated and their active widgets are + /// scheduled for layout again. #[track_caller] pub fn register_font( &mut self, family: impl AsRef, data: impl AsRef<[u8]> + Send + Sync + 'static, ) -> Result<(), crate::FontRegistrationError> { - self.data.text.register_font(family, data) + let owners = { + let mut text = self.data.text.borrow_mut(); + text.register_font(family, data)?; + text.invalidate_all() + }; + let active: Vec = { + let render = self.render_state.get(); + owners + .into_iter() + .filter(|owner| render.active.contains_key(owner)) + .collect() + }; + self.data.widgets.needs_redraw.extend(active); + Ok(()) } pub fn is_font_registered(&self, family: impl AsRef) -> bool { - self.data.text.is_font_registered(family) + self.data.text.borrow().is_font_registered(family) } /// A read-only handle to the retained result of the last completed frame. @@ -96,7 +108,7 @@ impl Ui { } pub fn set_density(&mut self, density: f32) { - self.data.text.density = density; + self.data.text.borrow_mut().density = density; self.render_state.get_mut().set_density(density); } } @@ -172,5 +184,6 @@ pub trait UiRsc { } self.ui_mut().textures.free(); self.ui_mut().paints.free_released(); + self.ui_mut().text.borrow_mut().free_released(); } } diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index caaa0af..9d2989f 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -1,6 +1,6 @@ use crate::{ - Axis, Len, MoveOffset, PaintId, RegionAlign, RenderedText, Size, StrongWidget, TextAttrs, - TextBuffer, TextData, TextureHandle, UiData, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, + Axis, Len, MoveOffset, PaintId, RegionAlign, RenderedText, Size, StrongWidget, TextHandle, + TextResources, TextureHandle, UiData, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId, render::{ Drawn, GlyphPrimitive, IMAGE_BINDING, Mask, MaskIdx, MoveIdx, NOT_DRAWN, Primitive, @@ -9,6 +9,7 @@ use crate::{ ui::render_state::Retained, util::Vec2, }; +use std::{cell::RefCell, rc::Rc}; pub struct Painter<'a> { pub(super) render_state: &'a mut UiRenderState, @@ -413,21 +414,16 @@ impl<'a> Painter<'a> { self.own(h); } - pub fn render_text( - &mut self, - buffer: &mut TextBuffer, - attrs: &TextAttrs, - width: Option, - ) -> RenderedText { + pub fn render_text(&mut self, text: &TextHandle, width: Option) -> RenderedText { let density = self.render_state.density; - self.render_state.shape_count += 1; let ui: &mut UiData = self.rsc.ui_mut(); - ui.text - .render(buffer, attrs, width, &mut ui.textures, density) + let (rendered, prepared) = text.render(width, self.id, &mut ui.textures, density); + self.render_state.shape_count += u64::from(prepared); + rendered } - pub fn atlas_generation(&mut self) -> u64 { - self.rsc.ui_mut().text.atlas.generation() + fn atlas_generation(&self) -> u64 { + self.rsc.ui().text.borrow().atlas.generation() } pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) { @@ -493,8 +489,8 @@ impl<'a> Painter<'a> { self.region.size().to_abs(self.render_state.output_size) } - pub fn text_data(&mut self) -> &mut TextData { - &mut self.rsc.ui_mut().text + pub fn text_resources(&mut self) -> Rc> { + self.rsc.ui().text.clone() } pub fn child_layer(&mut self) { diff --git a/core/src/util/slot.rs b/core/src/util/slot.rs index 498a084..c6768d7 100644 --- a/core/src/util/slot.rs +++ b/core/src/util/slot.rs @@ -71,6 +71,10 @@ impl SlotVec { pub fn is_empty(&self) -> bool { self.len() == 0 } + + pub fn values_mut(&mut self) -> impl Iterator { + self.data.iter_mut().filter_map(|(_, value)| value.as_mut()) + } } impl Default for SlotVec { diff --git a/examples/tabs/lib.rs b/examples/tabs/lib.rs index 46f8a04..e435fb3 100644 --- a/examples/tabs/lib.rs +++ b/examples/tabs/lib.rs @@ -8,8 +8,8 @@ pub(crate) fn update_info(rsc: &mut Rsc, info: WeakWidget, vie rsc.widgets().len(), render_state.get().active_widgets(), ); - if new != *rsc.widgets()[info].content { - *rsc.widgets_mut()[info].content = new; + if new != rsc.widgets()[info].content() { + rsc.widgets_mut()[info].set_text(new); } } @@ -118,7 +118,7 @@ where .attr::(()) .on(Submit, move |ctx, rsc: &mut Rsc| { let w = ctx.widget; - let content = w.edit(rsc).take(); + let content = w(rsc).take(); let text = wtext(content) .editable(EditMode::MultiLine) .size(30) diff --git a/readme.md b/readme.md index dbf9b43..99d48d2 100644 --- a/readme.md +++ b/readme.md @@ -59,7 +59,7 @@ the retained widget tree dirty: ```rust rsc.spawn_task(async move |mut ctx| { let text = load_text().await; - ctx.update(move |_, rsc| label.edit(rsc).set(&text)); + ctx.update(move |_, rsc| label(rsc).set(&text)); }); ``` diff --git a/src/android/ime.rs b/src/android/ime.rs index d1a992f..4ad6f13 100644 --- a/src/android/ime.rs +++ b/src/android/ime.rs @@ -43,11 +43,11 @@ impl IrisViewPeer { return; }; let content = text.text(); - let sel_start = byte_to_utf16(content, sel.start) as i32; - let sel_end = byte_to_utf16(content, sel.end) as i32; + let sel_start = byte_to_utf16(&content, sel.start) as i32; + let sel_end = byte_to_utf16(&content, sel.end) as i32; let compose_len = self.state.android_state().compose_len; let (comp_start, comp_end) = if compose_len > 0 { - let caret = byte_to_utf16(content, text.caret().unwrap_or(sel.end)) as i32; + let caret = byte_to_utf16(&content, text.caret().unwrap_or(sel.end)) as i32; (caret - compose_len as i32, caret) } else { (-1, -1) @@ -89,16 +89,12 @@ impl InputConnection for IrisViewPeer { if let Some(focus) = self.focus() { let text = focus.get(&self.rsc); let sel = text.selection_range().unwrap_or(0..0); - let start = byte_to_utf16(text.text(), sel.start) as i32; - let end = byte_to_utf16(text.text(), sel.end) as i32; + let content = text.text(); + let start = byte_to_utf16(&content, sel.start) as i32; + let end = byte_to_utf16(&content, sel.end) as i32; out_attrs.set_initial_sel_start(&mut ctx.env, start); out_attrs.set_initial_sel_end(&mut ctx.env, end); - let caps = caps_mode( - &mut ctx.env, - text.text(), - start as usize, - CAP_MODE_SENTENCES, - ); + let caps = caps_mode(&mut ctx.env, &content, start as usize, CAP_MODE_SENTENCES); out_attrs.set_initial_caps_mode(&mut ctx.env, caps); } } @@ -114,10 +110,11 @@ impl InputConnection for IrisViewPeer { let focus = self.focus()?; let text = focus.get(&self.rsc); let sel = text.selection_range()?; - let end_16 = byte_to_utf16(text.text(), sel.start); + let content = text.text(); + let end_16 = byte_to_utf16(&content, sel.start); let start_16 = end_16.saturating_sub(n as usize); - let start = utf16_to_byte(text.text(), start_16); - Some(Cow::Borrowed(&text.text()[start..sel.start])) + let start = utf16_to_byte(&content, start_16); + Some(Cow::Owned(content[start..sel.start].to_owned())) } fn text_after_cursor<'slf>( @@ -131,11 +128,12 @@ impl InputConnection for IrisViewPeer { let focus = self.focus()?; let text = focus.get(&self.rsc); let sel = text.selection_range()?; - let len_16 = byte_to_utf16(text.text(), text.text().len()); - let start_16 = byte_to_utf16(text.text(), sel.end); + let content = text.text(); + let len_16 = byte_to_utf16(&content, content.len()); + let start_16 = byte_to_utf16(&content, sel.end); let end_16 = (start_16 + n as usize).min(len_16); - let end = utf16_to_byte(text.text(), end_16); - Some(Cow::Borrowed(&text.text()[sel.end..end])) + let end = utf16_to_byte(&content, end_16); + Some(Cow::Owned(content[sel.end..end].to_owned())) } fn selected_text<'slf>(&'slf mut self, _ctx: &mut CallbackCtx) -> Option> { @@ -151,8 +149,9 @@ impl InputConnection for IrisViewPeer { let Some(caret) = text.caret() else { return 0; }; - let off = byte_to_utf16(text.text(), caret); - caps_mode(&mut ctx.env, text.text(), off, req_modes) + let content = text.text(); + let off = byte_to_utf16(&content, caret); + caps_mode(&mut ctx.env, &content, off, req_modes) } fn delete_surrounding_text( @@ -170,12 +169,13 @@ impl InputConnection for IrisViewPeer { }; let content = text.text(); let start_16 = - byte_to_utf16(content, sel.start).saturating_sub(before_length.max(0) as usize); - let len_16 = byte_to_utf16(content, content.len()); - let end_16 = (byte_to_utf16(content, sel.end) + after_length.max(0) as usize).min(len_16); - let start = utf16_to_byte(content, start_16); - let end = utf16_to_byte(content, end_16); - focus.edit(&mut self.rsc).delete_byte_range(start, end); + byte_to_utf16(&content, sel.start).saturating_sub(before_length.max(0) as usize); + let len_16 = byte_to_utf16(&content, content.len()); + let end_16 = (byte_to_utf16(&content, sel.end) + after_length.max(0) as usize).min(len_16); + let start = utf16_to_byte(&content, start_16); + let end = utf16_to_byte(&content, end_16); + drop(content); + focus(&mut self.rsc).delete_byte_range(start, end); self.after_input(ctx); true } @@ -199,7 +199,7 @@ impl InputConnection for IrisViewPeer { return false; }; let compose_len = self.state.android_state().compose_len; - focus.edit(&mut self.rsc).replace(compose_len, text); + focus(&mut self.rsc).replace(compose_len, text); self.state.android_state_mut().compose_len = text.chars().count(); self.after_input(ctx); true @@ -225,8 +225,9 @@ impl InputConnection for IrisViewPeer { }; let text = focus.get(&self.rsc); let content = text.text(); - let byte = utf16_to_byte(content, end.max(0) as usize); - focus.edit(&mut self.rsc).set_cursor_byte(byte); + let byte = utf16_to_byte(&content, end.max(0) as usize); + drop(content); + focus(&mut self.rsc).set_cursor_byte(byte); let _ = start; self.after_input(ctx); true diff --git a/src/android/input.rs b/src/android/input.rs index f6de0da..1d6e815 100644 --- a/src/android/input.rs +++ b/src/android/input.rs @@ -19,7 +19,7 @@ pub(super) fn on_key<'local, State: AndroidAppState>( let Some(focus) = state.android_state().focus else { return false; }; - let mut text = focus.edit(rsc); + let text = focus(rsc); match key_code { Keycode::Del => text.backspace(false), Keycode::ForwardDel => text.delete(false), diff --git a/src/android/view.rs b/src/android/view.rs index 95038e1..2628f1b 100644 --- a/src/android/view.rs +++ b/src/android/view.rs @@ -217,7 +217,7 @@ impl IrisViewPeer { if old_focus != ui_state.focus && let Some(old) = old_focus { - old.edit(&mut self.rsc).deselect(); + old(&mut self.rsc).deselect(); } if std::mem::take(&mut ui_state.pending_show_keyboard) { show_soft_input(&mut ctx.env, &ctx.view); @@ -619,7 +619,7 @@ impl ViewPeer for IrisViewPeer { if !gain_focus { let ui_state = self.state.android_state_mut(); if let Some(focus) = ui_state.focus.take() { - focus.edit(&mut self.rsc).deselect(); + focus(&mut self.rsc).deselect(); } } self.after_input(ctx); @@ -646,8 +646,8 @@ impl ViewPeer for IrisViewPeer { log::info!( "iris surface: surface_changed {width}x{height} already_live={already_live} \ glyphs_cached={} atlas_pages={}", - self.rsc.ui().text.atlas.glyph_count(), - self.rsc.ui().text.atlas.page_count(), + self.rsc.ui().text.borrow().atlas.glyph_count(), + self.rsc.ui().text.borrow().atlas.page_count(), ); if already_live { let ui_state = self.state.android_state_mut(); @@ -691,8 +691,8 @@ impl ViewPeer for IrisViewPeer { "iris surface: new renderer built ({:?}), re-uploading textures: \ glyphs={} pages={}", renderer.adapter_backend, - self.rsc.ui().text.atlas.glyph_count(), - self.rsc.ui().text.atlas.page_count(), + self.rsc.ui().text.borrow().atlas.glyph_count(), + self.rsc.ui().text.borrow().atlas.page_count(), ); self.rsc.ui_mut().textures.reupload(); self.rsc.ui_mut().paints.reupload(); @@ -716,8 +716,8 @@ impl ViewPeer for IrisViewPeer { log::info!( "iris surface: surface_destroyed, tearing the renderer down \ (glyphs_cached={} atlas_pages={})", - self.rsc.ui().text.atlas.glyph_count(), - self.rsc.ui().text.atlas.page_count(), + self.rsc.ui().text.borrow().atlas.glyph_count(), + self.rsc.ui().text.borrow().atlas.page_count(), ); self.state.android_state_mut().renderer = None; } diff --git a/src/desktop/mod.rs b/src/desktop/mod.rs index 7cf5479..10a982b 100644 --- a/src/desktop/mod.rs +++ b/src/desktop/mod.rs @@ -250,7 +250,7 @@ impl AppState for DesktopApp { if old != ui_state.focus && let Some(old) = old { - old.edit(rsc).deselect(); + old(rsc).deselect(); } match &event { WindowEvent::CloseRequested => event_loop.exit(), @@ -315,7 +315,7 @@ impl AppState for DesktopApp { && let Some(sel) = ui_state.focus && event.state.is_pressed() { - let mut text = sel.edit(rsc); + let text = sel(rsc); match text.apply_event(event, &ui_state.input.modifiers) { TextInputResult::Unfocus => { ui_state.focus = None; @@ -346,7 +346,7 @@ impl AppState for DesktopApp { if !rsc.events.controllers.command_target_blocks_input() && let Some(sel) = ui_state.focus { - let mut text = sel.edit(rsc); + let text = sel(rsc); match ime { Ime::Enabled | Ime::Disabled => (), Ime::Preedit(content, _pos) => { diff --git a/src/layout_tests.rs b/src/layout_tests.rs index 7427494..b775fa7 100644 --- a/src/layout_tests.rs +++ b/src/layout_tests.rs @@ -467,10 +467,8 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() { render.resize((1080.0, 2298.0)); render.update(&root, &mut rsc); - field - .edit(&mut rsc) - .select(vec2(40.0, 2250.0), vec2(1080.0, 2298.0), false, false); - field.edit(&mut rsc).insert("a"); + field(&mut rsc).select(vec2(40.0, 2250.0), vec2(1080.0, 2298.0), false, false); + field(&mut rsc).insert("a"); render.update(&root, &mut rsc); let before_px = render.window_region(&field, &rsc).unwrap(); @@ -485,7 +483,7 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() { render.resize((1080.0, 1478.0)); render.update(&root, &mut rsc); - field.edit(&mut rsc).insert("b"); + field(&mut rsc).insert("b"); render.update(&root, &mut rsc); let after_px = render.window_region(&field, &rsc).unwrap(); diff --git a/src/rsc/attr.rs b/src/rsc/attr.rs index 8fbc344..d8789be 100644 --- a/src/rsc/attr.rs +++ b/src/rsc/attr.rs @@ -127,29 +127,29 @@ fn on_press( match sense { CursorSense::PressStart(_) => { let recent = state.recent_click(); - id.edit(rsc).text.press_origin = Some(pos); - id.edit(rsc).select(pos, size, false, recent); + id(rsc).press_origin = Some(pos); + id(rsc).select(pos, size, false, recent); } CursorSense::Pressing(_) | CursorSense::PressEnd(_) => { - let mut ctx = id.edit(rsc); - let Some(origin) = ctx.text.press_origin else { + let ctx = id(rsc); + let Some(origin) = ctx.press_origin else { return; }; let (dx, dy) = (pos.x - origin.x, pos.y - origin.y); if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() { - ctx.text.press_origin = None; + ctx.press_origin = None; return; } let ended = matches!(sense, CursorSense::PressEnd(_)); if ended { - ctx.text.press_origin = None; + ctx.press_origin = None; } ctx.select(pos, size, true, false); if ended && dx.abs() <= DRAG_SLOP && dy.abs() <= DRAG_SLOP { state.focus_gained(render.window_region(&id, &*rsc)); } } - CursorSense::Cancel => id.edit(rsc).text.press_origin = None, + CursorSense::Cancel => id(rsc).press_origin = None, _ => {} } return; @@ -157,24 +157,24 @@ fn on_press( match sense { CursorSense::PressStart(_) => { - id.edit(rsc).text.press_origin = Some(pos); + id(rsc).press_origin = Some(pos); } CursorSense::Pressing(_) => { - let ctx = id.edit(rsc); - if let Some(origin) = ctx.text.press_origin + let ctx = id(rsc); + if let Some(origin) = ctx.press_origin && ((pos.x - origin.x).abs() > DRAG_SLOP || (pos.y - origin.y).abs() > DRAG_SLOP) { - ctx.text.press_origin = None; + ctx.press_origin = None; } } // The gesture was taken by somebody else, so it is not a tap and // must not grant focus when it ends out of this widget's sight. - CursorSense::Cancel => id.edit(rsc).text.press_origin = None, + CursorSense::Cancel => id(rsc).press_origin = None, CursorSense::PressEnd(_) => { - let was_tap = id.edit(rsc).text.press_origin.take().is_some(); + let was_tap = id(rsc).press_origin.take().is_some(); if was_tap { let recent = state.recent_click(); - id.edit(rsc).select(pos, size, false, recent); + id(rsc).select(pos, size, false, recent); state.set_focus(Some(id)); state.focus_gained(render.window_region(&id, &*rsc)); } diff --git a/src/widget/text/build.rs b/src/widget/text/build.rs index 0d442a5..1211327 100644 --- a/src/widget/text/build.rs +++ b/src/widget/text/build.rs @@ -91,12 +91,10 @@ impl TextBuilderOutput for TextOutput { let mut buf = TextBuffer::new(&builder.content); buf.set_spans(builder.spans); let hint = builder.hint.get(state); - let mut text = Text { - content: builder.content.into(), - view: TextView::new(buf, builder.attrs, hint), - }; - text.content.changed = false; - text + let resources = state.ui().text.clone(); + Text { + view: TextView::new(TextResources::add(resources, buf, builder.attrs), hint), + } } } @@ -113,8 +111,12 @@ impl TextBuilderOutput for TextEditOutput { ) -> Self::Output { let mut buf = TextBuffer::new(&builder.content); buf.set_spans(builder.spans); + let resources = state.ui().text.clone(); TextEdit::new( - TextView::new(buf, builder.attrs, builder.hint.get(state)), + TextView::new( + TextResources::add(resources, buf, builder.attrs), + builder.hint.get(state), + ), builder.output.mode, ) } diff --git a/src/widget/text/edit.rs b/src/widget/text/edit.rs index 1056524..46dc012 100644 --- a/src/widget/text/edit.rs +++ b/src/widget/text/edit.rs @@ -1,9 +1,10 @@ use crate::prelude::*; -use iris_core::{PaintId, TextData}; +use iris_core::PaintId; use parley::{Affinity, Layout, Selection}; -use std::ops::{Deref, DerefMut}; - -use super::selection_layout; +use std::{ + cell::Ref, + ops::{Deref, DerefMut}, +}; #[cfg(not(target_os = "android"))] use winit::{ event::KeyEvent, @@ -47,15 +48,15 @@ impl TextEdit { } pub fn selected_text(&self) -> Option { - self.view.selection.selected_text(self.view.buf.text()) + self.view.selection.selected_text(&self.view.text.text()) } /// The field's content. Byte-indexed, like everything else here since /// I1 moved to parley -- an IME bridge (`android/ime.rs`) converts to /// and from UTF-16 code units at its own edge rather than this type /// knowing about that encoding. - pub fn text(&self) -> &str { - self.view.buf.text() + pub fn text(&self) -> Ref<'_, str> { + self.view.text.text() } /// The selection as a byte range, collapsed to `caret..caret` when @@ -89,21 +90,15 @@ impl Widget for TextEdit { } } -pub struct TextEditCtx<'a> { - pub text: &'a mut TextEdit, - pub data: &'a mut TextData, -} - -impl<'a> TextEditCtx<'a> { +impl TextEdit { fn selection_ctx(&mut self) -> TextSelectionCtx<'_> { TextSelectionCtx { - view: &mut self.text.view, - data: self.data, + view: &mut self.view, } } - fn layout(&mut self) -> &Layout { - selection_layout(&mut self.text.view, self.data) + fn layout(&self) -> Ref<'_, Layout> { + self.view.text.layout() } #[cfg_attr(target_os = "android", allow(dead_code))] @@ -112,44 +107,44 @@ impl<'a> TextEditCtx<'a> { } pub fn take(&mut self) -> String { - let text = self.text.view.buf.text().to_string(); + let text = self.view.text.text().to_string(); self.set(""); text } pub fn set(&mut self, text: &str) { let text = self.string(text); - self.text.view.buf.set_text(text); - self.text.view.buf.changed = true; - self.text.view.selection.deselect(); + self.view.text.set_text(text); + self.view.selection.deselect(); } pub fn set_with_spans(&mut self, text: &str, spans: Vec) { let text = self.string(text); - self.text.view.buf.set_text(text); - self.text.view.buf.set_spans(spans); - self.text.view.selection.deselect(); + self.view.text.set_text(text); + self.view.text.set_spans(spans); + self.view.selection.deselect(); } pub fn motion(&mut self, motion: Motion, select: bool) { - let Some(sel) = self.text.view.selection.range else { + let Some(sel) = self.view.selection.range else { return; }; let layout = self.layout(); let sel = if !select && !sel.is_collapsed() { match motion { Motion::Left | Motion::LeftWord => { - Selection::from(sel.text_range().start_cursor(layout)) + Selection::from(sel.text_range().start_cursor(&layout)) } Motion::Right | Motion::RightWord => { - Selection::from(sel.text_range().end_cursor(layout)) + Selection::from(sel.text_range().end_cursor(&layout)) } - _ => apply_motion(sel, layout, motion, false), + _ => apply_motion(sel, &layout, motion, false), } } else { - apply_motion(sel, layout, motion, select) + apply_motion(sel, &layout, motion, select) }; - self.text.view.selection.range = Some(sel); + drop(layout); + self.view.selection.range = Some(sel); } pub fn replace(&mut self, len: usize, text: &str) { @@ -161,7 +156,7 @@ impl<'a> TextEditCtx<'a> { } fn string(&self, text: &str) -> String { - if self.text.mode == EditMode::SingleLine { + if self.mode == EditMode::SingleLine { text.replace('\n', "") } else { text.to_string() @@ -178,7 +173,7 @@ impl<'a> TextEditCtx<'a> { return; } self.clear_span(); - let at = match self.text.view.selection.range { + let at = match self.view.selection.range { Some(sel) => sel.focus().index(), // No caret means nowhere to put the text, so this drops the // keystroke -- which is invisible, and was the whole of the @@ -196,22 +191,24 @@ impl<'a> TextEditCtx<'a> { return; } }; - let at = at.min(self.text.view.buf.text().len()); - self.text.view.buf.edit().insert_str(at, text); - self.text.view.buf.changed = true; + let at = at.min(self.view.text.text().len()); + self.view + .text + .edit_text(|content| content.insert_str(at, text)); self.set_caret(at + text.len()); } pub fn clear_span(&mut self) -> bool { - let Some(sel) = self.text.view.selection.range else { + let Some(sel) = self.view.selection.range else { return false; }; if sel.is_collapsed() { return false; } let range = sel.text_range(); - self.text.view.buf.edit().replace_range(range.clone(), ""); - self.text.view.buf.changed = true; + self.view + .text + .edit_text(|content| content.replace_range(range.clone(), "")); self.set_caret(range.start); true } @@ -221,7 +218,7 @@ impl<'a> TextEditCtx<'a> { } pub fn newline(&mut self) { - if self.text.mode == EditMode::MultiLine { + if self.mode == EditMode::MultiLine { self.insert_str("\n"); } } @@ -230,7 +227,7 @@ impl<'a> TextEditCtx<'a> { if self.clear_span() { return; } - let Some(sel) = self.text.view.selection.range else { + let Some(sel) = self.view.selection.range else { return; }; let end = sel.focus().index(); @@ -239,10 +236,11 @@ impl<'a> TextEditCtx<'a> { } let layout = self.layout(); let start = if word { - sel.focus().previous_logical_word(layout).index() + sel.focus().previous_logical_word(&layout).index() } else { - sel.focus().previous_visual(layout).index() + sel.focus().previous_visual(&layout).index() }; + drop(layout); self.delete_range(start, end); } @@ -250,30 +248,32 @@ impl<'a> TextEditCtx<'a> { if self.clear_span() { return; } - let Some(sel) = self.text.view.selection.range else { + let Some(sel) = self.view.selection.range else { return; }; let start = sel.focus().index(); - if start >= self.text.view.buf.text().len() { + if start >= self.view.text.text().len() { return; } let layout = self.layout(); let end = if word { - sel.focus().next_logical_word(layout).index() + sel.focus().next_logical_word(&layout).index() } else { - sel.focus().next_visual(layout).index() + sel.focus().next_visual(&layout).index() }; + drop(layout); self.delete_range(start, end); } fn delete_range(&mut self, start: usize, end: usize) { - let len = self.text.view.buf.text().len(); + let len = self.view.text.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.changed = true; + self.view + .text + .edit_text(|content| content.replace_range(start..end, "")); self.set_caret(start); } @@ -326,20 +326,17 @@ impl<'a> TextEditCtx<'a> { #[cfg(not(target_os = "android"))] pub fn apply_event(&mut self, event: &KeyEvent, modifiers: &Modifiers) -> TextInputResult { - let old = ( - self.text.view.buf.text().to_string(), - self.text.view.selection.range, - ); + let old = (self.view.text.text().to_string(), self.view.selection.range); let mut undo = false; let res = self.apply_event_inner(event, modifiers, &mut undo); if undo { - if let Some((old, selection)) = self.text.history.pop() { + if let Some((old, selection)) = self.history.pop() { self.set(&old); - self.text.view.selection.range = selection; + self.view.selection.range = selection; self.refresh(); } - } else if self.text.view.buf.text() != old.0 { - self.text.history.push(old); + } else if self.view.text.text().as_ref() != old.0 { + self.history.push(old); } res } @@ -394,12 +391,12 @@ impl<'a> TextEditCtx<'a> { match text.as_str() { "v" => return TextInputResult::Paste, "c" => { - if let Some(content) = self.text.selected_text() { + if let Some(content) = self.selected_text() { return TextInputResult::Copy(content); } } "x" => { - if let Some(content) = self.text.selected_text() { + if let Some(content) = self.selected_text() { self.clear_span(); return TextInputResult::Copy(content); } @@ -492,226 +489,213 @@ impl DerefMut for TextEdit { } } -pub trait TextEditable { - fn edit<'a>(&self, ui: &'a mut impl UiRsc) -> TextEditCtx<'a>; -} - -impl> TextEditable for I { - fn edit<'a>(&self, ui: &'a mut impl UiRsc) -> TextEditCtx<'a> { - let ui: &mut UiData = ui.ui_mut(); - TextEditCtx { - text: ui.widgets.get_mut(self).unwrap(), - data: &mut ui.text, - } - } -} - #[cfg(test)] mod tests { use super::*; - use iris_core::{TextAttrs, TextBuffer}; + use iris_core::{TextAttrs, TextBuffer, TextResources}; - fn edit(text: &str, mode: EditMode) -> (TextEdit, TextData) { - let view = TextView::new(TextBuffer::new(text), TextAttrs::default(), None); - (TextEdit::new(view, mode), TextData::default()) + fn edit(text: &str, mode: EditMode) -> TextEdit { + let resources = std::rc::Rc::new(std::cell::RefCell::new(TextResources::default())); + let handle = TextResources::add(resources, TextBuffer::new(text), TextAttrs::default()); + TextEdit::new(TextView::new(handle, None), mode) } - fn ctx<'a>(text: &'a mut TextEdit, data: &'a mut TextData) -> TextEditCtx<'a> { - TextEditCtx { text, data } + fn ctx(text: &mut TextEdit) -> &mut TextEdit { + text } fn content(text: &TextEdit) -> String { - text.buf.text().to_string() + text.text().to_string() } #[test] fn insert_at_the_caret() { - let (mut t, mut d) = edit("ac", EditMode::SingleLine); - ctx(&mut t, &mut d).set_caret(1); - ctx(&mut t, &mut d).insert("b"); + let mut t = edit("ac", EditMode::SingleLine); + ctx(&mut t).set_caret(1); + ctx(&mut t).insert("b"); assert_eq!(content(&t), "abc"); assert_eq!(t.caret(), Some(2)); } #[test] fn backspace_removes_the_character_before_the_caret() { - let (mut t, mut d) = edit("abc", EditMode::SingleLine); - ctx(&mut t, &mut d).set_caret(2); - ctx(&mut t, &mut d).backspace(false); + let mut t = edit("abc", EditMode::SingleLine); + ctx(&mut t).set_caret(2); + ctx(&mut t).backspace(false); assert_eq!(content(&t), "ac"); } #[test] fn backspace_at_the_start_does_nothing() { - let (mut t, mut d) = edit("abc", EditMode::SingleLine); - ctx(&mut t, &mut d).set_caret(0); - ctx(&mut t, &mut d).backspace(false); + let mut t = edit("abc", EditMode::SingleLine); + ctx(&mut t).set_caret(0); + ctx(&mut t).backspace(false); assert_eq!(content(&t), "abc"); } #[test] fn delete_removes_the_character_after_the_caret() { - let (mut t, mut d) = edit("abc", EditMode::SingleLine); - ctx(&mut t, &mut d).set_caret(1); - ctx(&mut t, &mut d).delete(false); + let mut t = edit("abc", EditMode::SingleLine); + ctx(&mut t).set_caret(1); + ctx(&mut t).delete(false); assert_eq!(content(&t), "ac"); } #[test] fn delete_at_the_end_does_nothing() { - let (mut t, mut d) = edit("abc", EditMode::SingleLine); - ctx(&mut t, &mut d).set_caret(3); - ctx(&mut t, &mut d).delete(false); + let mut t = edit("abc", EditMode::SingleLine); + ctx(&mut t).set_caret(3); + ctx(&mut t).delete(false); assert_eq!(content(&t), "abc"); } #[test] fn select_all_then_typing_replaces_everything() { - let (mut t, mut d) = edit("hello", EditMode::SingleLine); - ctx(&mut t, &mut d).select_all(); + let mut t = edit("hello", EditMode::SingleLine); + ctx(&mut t).select_all(); assert_eq!(t.selected_text().as_deref(), Some("hello")); - ctx(&mut t, &mut d).insert("x"); + ctx(&mut t).insert("x"); assert_eq!(content(&t), "x"); } #[test] fn clearing_a_span_leaves_the_caret_at_its_start() { - let (mut t, mut d) = edit("abcdef", EditMode::SingleLine); - ctx(&mut t, &mut d).select_all(); - assert!(ctx(&mut t, &mut d).clear_span()); + let mut t = edit("abcdef", EditMode::SingleLine); + ctx(&mut t).select_all(); + assert!(ctx(&mut t).clear_span()); assert_eq!(content(&t), ""); assert_eq!(t.caret(), Some(0)); } #[test] fn tapping_an_empty_field_places_a_caret_so_typing_lands() { - let (mut t, mut d) = edit("", EditMode::MultiLine); - ctx(&mut t, &mut d).select(vec2(40.0, 20.0), vec2(1080.0, 2400.0), false, false); + let mut t = edit("", EditMode::MultiLine); + ctx(&mut t).select(vec2(40.0, 20.0), vec2(1080.0, 2400.0), false, false); assert!(t.caret().is_some(), "a tap must leave a caret behind"); - ctx(&mut t, &mut d).insert("hi"); + ctx(&mut t).insert("hi"); assert_eq!(content(&t), "hi"); } #[test] fn tapping_past_the_end_of_the_text_clamps_to_the_end() { - let (mut t, mut d) = edit("abc", EditMode::MultiLine); - ctx(&mut t, &mut d).select(vec2(9000.0, 9000.0), vec2(1080.0, 2400.0), false, false); + let mut t = edit("abc", EditMode::MultiLine); + ctx(&mut t).select(vec2(9000.0, 9000.0), vec2(1080.0, 2400.0), false, false); assert_eq!(t.caret(), Some(3)); } #[test] fn dragging_without_a_previous_selection_selects_nothing() { - let (mut t, mut d) = edit("abc", EditMode::MultiLine); - ctx(&mut t, &mut d).select(vec2(10.0, 10.0), vec2(1080.0, 2400.0), true, false); + let mut t = edit("abc", EditMode::MultiLine); + ctx(&mut t).select(vec2(10.0, 10.0), vec2(1080.0, 2400.0), true, false); assert!(t.selection_range().is_none()); } #[test] fn a_single_line_field_refuses_newlines() { - let (mut t, mut d) = edit("", EditMode::SingleLine); - ctx(&mut t, &mut d).set_caret(0); - ctx(&mut t, &mut d).insert("a\nb"); + let mut t = edit("", EditMode::SingleLine); + ctx(&mut t).set_caret(0); + ctx(&mut t).insert("a\nb"); assert_eq!(content(&t), "ab"); - ctx(&mut t, &mut d).newline(); + ctx(&mut t).newline(); assert_eq!(content(&t), "ab"); } #[test] fn a_multi_line_field_keeps_newlines() { - let (mut t, mut d) = edit("", EditMode::MultiLine); - ctx(&mut t, &mut d).set_caret(0); - ctx(&mut t, &mut d).insert("a\nb"); + let mut t = edit("", EditMode::MultiLine); + ctx(&mut t).set_caret(0); + ctx(&mut t).insert("a\nb"); assert_eq!(content(&t), "a\nb"); } #[test] fn take_empties_the_field_and_hands_back_what_was_there() { - let (mut t, mut d) = edit("some text", EditMode::SingleLine); - assert_eq!(ctx(&mut t, &mut d).take(), "some text"); + let mut t = edit("some text", EditMode::SingleLine); + assert_eq!(ctx(&mut t).take(), "some text"); assert_eq!(content(&t), ""); } #[test] fn ime_preedit_replaces_its_own_previous_text() { - let (mut t, mut d) = edit("", EditMode::SingleLine); - ctx(&mut t, &mut d).set_caret(0); - ctx(&mut t, &mut d).replace(0, "n"); + let mut t = edit("", EditMode::SingleLine); + ctx(&mut t).set_caret(0); + ctx(&mut t).replace(0, "n"); assert_eq!(content(&t), "n"); - ctx(&mut t, &mut d).replace(1, "ni"); + ctx(&mut t).replace(1, "ni"); assert_eq!(content(&t), "ni"); - ctx(&mut t, &mut d).replace(2, "に"); + ctx(&mut t).replace(2, "に"); assert_eq!(content(&t), "に"); } #[test] fn composing_advances_the_caret_with_the_growing_text() { - let (mut t, mut d) = edit("", EditMode::SingleLine); - ctx(&mut t, &mut d).set_caret(0); - ctx(&mut t, &mut d).replace(0, "h"); + let mut t = edit("", EditMode::SingleLine); + ctx(&mut t).set_caret(0); + ctx(&mut t).replace(0, "h"); assert_eq!(t.caret(), Some(1)); - ctx(&mut t, &mut d).replace(1, "hi"); + ctx(&mut t).replace(1, "hi"); assert_eq!(content(&t), "hi"); assert_eq!(t.caret(), Some(2)); - ctx(&mut t, &mut d).replace(2, "hit"); + ctx(&mut t).replace(2, "hit"); assert_eq!(content(&t), "hit"); assert_eq!(t.caret(), Some(3)); } #[test] fn committing_composed_text_leaves_it_in_place_with_the_caret_after_it() { - let (mut t, mut d) = edit("say ", EditMode::SingleLine); - ctx(&mut t, &mut d).set_caret(4); - ctx(&mut t, &mut d).replace(0, "hi"); + let mut t = edit("say ", EditMode::SingleLine); + ctx(&mut t).set_caret(4); + ctx(&mut t).replace(0, "hi"); assert_eq!(content(&t), "say hi"); - ctx(&mut t, &mut d).replace(0, " "); + ctx(&mut t).replace(0, " "); assert_eq!(content(&t), "say hi "); assert_eq!(t.caret(), Some(7)); } #[test] fn delete_byte_range_removes_exactly_that_range() { - let (mut t, mut d) = edit("hello world", EditMode::SingleLine); - ctx(&mut t, &mut d).delete_byte_range(5, 11); + let mut t = edit("hello world", EditMode::SingleLine); + ctx(&mut t).delete_byte_range(5, 11); assert_eq!(content(&t), "hello"); assert_eq!(t.caret(), Some(5)); } #[test] fn set_cursor_byte_collapses_to_a_caret_there() { - let (mut t, mut d) = edit("hello world", EditMode::SingleLine); - ctx(&mut t, &mut d).select_all(); - ctx(&mut t, &mut d).set_cursor_byte(5); + let mut t = edit("hello world", EditMode::SingleLine); + ctx(&mut t).select_all(); + ctx(&mut t).set_cursor_byte(5); assert_eq!(t.selected_text(), None); assert_eq!(t.caret(), Some(5)); } #[test] fn motion_moves_the_caret_and_shift_extends_a_span() { - let (mut t, mut d) = edit("abc", EditMode::SingleLine); - ctx(&mut t, &mut d).set_caret(0); - ctx(&mut t, &mut d).motion(Motion::Right, false); + let mut t = edit("abc", EditMode::SingleLine); + ctx(&mut t).set_caret(0); + ctx(&mut t).motion(Motion::Right, false); assert_eq!(t.caret(), Some(1)); - ctx(&mut t, &mut d).motion(Motion::Right, true); + ctx(&mut t).motion(Motion::Right, true); assert_eq!(t.selected_text().as_deref(), Some("b")); } #[test] fn an_unshifted_arrow_collapses_a_span_to_its_edge() { - let (mut t, mut d) = edit("abcdef", EditMode::SingleLine); - ctx(&mut t, &mut d).select_all(); - ctx(&mut t, &mut d).motion(Motion::Left, false); + let mut t = edit("abcdef", EditMode::SingleLine); + ctx(&mut t).select_all(); + ctx(&mut t).motion(Motion::Left, false); assert_eq!(t.caret(), Some(0)); - ctx(&mut t, &mut d).select_all(); - ctx(&mut t, &mut d).motion(Motion::Right, false); + ctx(&mut t).select_all(); + ctx(&mut t).motion(Motion::Right, false); assert_eq!(t.caret(), Some(6)); } #[test] fn multibyte_text_is_edited_by_byte_offset() { - let (mut t, mut d) = edit("aé", EditMode::SingleLine); - ctx(&mut t, &mut d).set_caret(3); - ctx(&mut t, &mut d).backspace(false); + let mut t = edit("aé", EditMode::SingleLine); + ctx(&mut t).set_caret(3); + ctx(&mut t).backspace(false); assert_eq!(content(&t), "a"); } } diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs index 42e50e6..831a7ac 100644 --- a/src/widget/text/mod.rs +++ b/src/widget/text/mod.rs @@ -4,87 +4,65 @@ mod selection; pub use build::*; pub use edit::*; -use iris_core::util::MutDetect; pub use selection::*; use crate::prelude::*; -use std::ops::{Deref, DerefMut}; pub struct Text { - pub content: MutDetect, view: TextView, } pub struct TextView { - pub attrs: MutDetect, - pub buf: MutDetect, - tex: Option, - width: Option, + text: TextHandle, pub hint: Option, selection: TextSelection, } impl TextView { fn is_blank(&self) -> bool { - self.buf.is_empty() + self.text.text().is_empty() } pub fn wrap_width(&self) -> Option { - self.width + self.text.width() } - pub fn new(buf: TextBuffer, attrs: TextAttrs, hint: Option) -> Self { + pub fn new(text: TextHandle, hint: Option) -> Self { Self { - attrs: attrs.into(), - buf: buf.into(), - tex: None, - width: None, + text, hint, selection: TextSelection::default(), } } pub fn region(&self) -> UiRegion { - self.tex() + let align = self.text.attrs().align; + self.text + .rendered() .map(|t| t.size) .unwrap_or(Vec2::ZERO) - .align(self.align) + .align(align) } fn render(&mut self, painter: &mut Painter) -> RenderedText { - let width = if self.attrs.wrap { + let width = if self.text.attrs().wrap { Some(painter.px_size().x) } else { None }; - let generation = painter.atlas_generation(); - if width == self.width - && let Some(tex) = &self.tex - && tex.generation == generation - && !self.attrs.changed - && !self.buf.changed - { - return tex.clone(); - } - self.width = width; - let tex = painter.render_text(&mut self.buf, &self.attrs, width); + let tex = painter.render_text(&self.text, width); if crate::diagnostics::trace_enabled() { log::debug!( target: "iris::frame", "iris text render: chars={} width={width:?} glyphs={} size={:?}", - self.buf.text().chars().count(), + self.text.text().chars().count(), tex.glyphs.len(), tex.size, ); } - self.tex = Some(tex.clone()); - self.attrs.changed = false; - self.buf.changed = false; tex } - pub fn tex(&self) -> Option<&RenderedText> { - self.tex.as_ref() - } + pub fn draw(&mut self, painter: &mut Painter) -> Size { let tex = self.render(painter); if self.is_blank() @@ -92,7 +70,7 @@ impl TextView { { return painter.widget(hint).size(); } - let region = tex.size.align(self.align); + let region = tex.size.align(self.text.attrs().align); let within = region.within(&painter.region()); painter.glyphs(&tex, within); Size::abs(tex.size) @@ -108,8 +86,10 @@ impl TextView { let Some(selection) = self.selection.range else { return used; }; - let layout = self.buf.layout(); - for (rect, _) in selection.geometry(layout) { + let geometry = self + .text + .with_layout(|layout, _| selection.geometry(layout)); + for (rect, _) in geometry { let size = vec2(rect.width() as f32, rect.height() as f32); let top_left = vec2(rect.x0 as f32, rect.y0 as f32); let paint = painter.paint(&PaintId::SKY); @@ -119,7 +99,9 @@ impl TextView { ); } if caret { - let caret = selection.focus().geometry(layout, CARET_WIDTH); + let caret = self + .text + .with_layout(|layout, _| selection.focus().geometry(layout, CARET_WIDTH)); let size = vec2(caret.width() as f32, caret.height() as f32); let top_left = vec2(caret.x0 as f32, caret.y0 as f32); let paint = painter.paint(&PaintId::WHITE); @@ -132,28 +114,23 @@ impl TextView { } pub fn content(&self) -> String { - self.buf.text().to_string() + self.text.text().to_string() + } + + pub fn update_attrs(&mut self, update: impl FnOnce(&mut TextAttrs) -> R) -> R { + self.text.update_attrs(update) } } impl Text { - pub fn new(content: impl Into) -> Self { - let content: String = content.into(); - Self { - view: TextView::new(TextBuffer::new(&content), TextAttrs::default(), None), - content: content.into(), - } - } - fn update_buf(&mut self) { - if self.content.changed { - self.content.changed = false; - self.view.buf.set_text(self.content.as_str()); + pub fn set_text(&mut self, content: impl Into) { + if self.view.text.set_text(content) { self.view.selection.deselect(); } } pub fn selected_text(&self) -> Option { - self.view.selection.selected_text(self.view.buf.text()) + self.view.selection.selected_text(&self.view.text.text()) } pub fn selection_range(&self) -> Option> { @@ -161,18 +138,22 @@ impl Text { } pub fn set_with_spans(&mut self, content: impl Into, spans: Vec) { - let content = content.into(); - *self.content = content.clone(); - self.content.changed = false; - self.view.buf.set_text(content); - self.view.buf.set_spans(spans); + self.view.text.set_text(content); + self.view.text.set_spans(spans); self.view.selection.deselect(); } + + pub fn content(&self) -> String { + self.view.content() + } + + pub fn update_attrs(&mut self, update: impl FnOnce(&mut TextAttrs) -> R) -> R { + self.view.update_attrs(update) + } } impl Widget for Text { fn draw(&mut self, painter: &mut Painter) { - self.update_buf(); let size = if self.view.selection.range.is_some() { self.view.draw_selectable(painter, false) } else { @@ -188,34 +169,6 @@ impl Widget for Text { pub(super) const CARET_WIDTH: f32 = 1.0; -impl Deref for Text { - type Target = TextAttrs; - - fn deref(&self) -> &Self::Target { - &self.view - } -} - -impl DerefMut for Text { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.view - } -} - -impl Deref for TextView { - type Target = TextAttrs; - - fn deref(&self) -> &Self::Target { - &self.attrs - } -} - -impl DerefMut for TextView { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.attrs - } -} - #[cfg(test)] mod tests { use crate::layout_tests::TestRsc; @@ -237,20 +190,20 @@ mod tests { let (mut rsc, _render, text, _root) = rendered_text("hello there"); text.selection(&mut rsc).select_all(); - let view = TextView::new(TextBuffer::new("hello there"), TextAttrs::default(), None); - let mut edit = TextEdit::new(view, EditMode::MultiLine); - let mut data = TextData::default(); - TextEditCtx { - text: &mut edit, - data: &mut data, - } - .select_all(); + let edit = wtext("hello there") + .editable(EditMode::MultiLine) + .add_strong(&mut rsc); + let edit_id = edit.weak(); + edit_id(&mut rsc).select_all(); assert_eq!( rsc.ui.widgets[text].selection_range(), - edit.selection_range() + rsc.ui.widgets[edit_id].selection_range() + ); + assert_eq!( + rsc.ui.widgets[text].selected_text(), + rsc.ui.widgets[edit_id].selected_text() ); - assert_eq!(rsc.ui.widgets[text].selected_text(), edit.selected_text()); } #[test] @@ -262,7 +215,7 @@ mod tests { Some("before") ); - *rsc.ui.widgets[text].content = "after".to_string(); + rsc.ui.widgets[text].set_text("after"); render.update(&root, &mut rsc); assert_eq!(rsc.ui.widgets[text].selected_text(), None); @@ -277,17 +230,15 @@ mod tests { render.update(&root, &mut rsc); let selected = render.active[&text.id()].primitives.len(); - let view = TextView::new(TextBuffer::new("selected"), TextAttrs::default(), None); - let edit = rsc - .ui - .widgets - .add_strong(TextEdit::new(view, EditMode::MultiLine)); + let edit = wtext("selected") + .editable(EditMode::MultiLine) + .add_strong(&mut rsc); let edit_id = edit.weak(); let edit_root = edit.any(); let mut edit_render = UiRenderState::new(); edit_render.resize((800.0, 600.0)); edit_render.update(&edit_root, &mut rsc); - edit_id.edit(&mut rsc).select_all(); + edit_id(&mut rsc).select_all(); edit_render.update(&edit_root, &mut rsc); let editable = edit_render.active[&edit_id.id()].primitives.len(); @@ -314,16 +265,16 @@ mod tests { render.resize((800.0, 600.0)); render.update(&root, &mut rsc); - let rasterised = rsc.ui.text.atlas.glyph_count(); + let rasterised = rsc.ui.text.borrow().atlas.glyph_count(); assert!(rasterised > 0, "the first frame rasterised no glyphs"); - rsc.ui.text.atlas.clear(); - assert_eq!(rsc.ui.text.atlas.glyph_count(), 0); + rsc.ui.text.borrow_mut().atlas.clear(); + assert_eq!(rsc.ui.text.borrow().atlas.glyph_count(), 0); render.resize((800.0, 600.0)); render.update(&root, &mut rsc); assert_eq!( - rsc.ui.text.atlas.glyph_count(), + rsc.ui.text.borrow().atlas.glyph_count(), rasterised, "the second frame re-emitted its cached glyphs instead of \ re-rendering them against the fresh atlas" diff --git a/src/widget/text/selection.rs b/src/widget/text/selection.rs index c6a6104..ad05316 100644 --- a/src/widget/text/selection.rs +++ b/src/widget/text/selection.rs @@ -1,6 +1,5 @@ use crate::prelude::*; -use iris_core::{PaintId, TextData}; -use parley::{Affinity, Layout, Selection as ParleySelection}; +use parley::{Affinity, Selection as ParleySelection}; use std::time::Instant; /// The selection state shared by display text and editable text. Editing, @@ -35,23 +34,21 @@ impl TextSelection { } } -/// Selection operations that need both a text widget's shaped buffer and -/// iris's text resources. `TextEditCtx` delegates to this same context rather -/// than maintaining an editable-only copy of the geometry and hit testing. +/// Selection operations shared by ordinary and editable text, using the +/// widget's arena-backed layout rather than maintaining an editable-only copy +/// of the geometry and hit testing. pub struct TextSelectionCtx<'a> { pub(super) view: &'a mut TextView, - pub(super) data: &'a mut TextData, } impl TextSelectionCtx<'_> { - pub(super) fn layout(&mut self) -> &Layout { - selection_layout(self.view, self.data) - } - pub(crate) fn refresh(&mut self) { if let Some(selection) = self.view.selection.range { - let layout = self.layout(); - self.view.selection.range = Some(selection.refresh(layout)); + self.view.selection.range = Some( + self.view + .text + .with_layout(|layout, _| selection.refresh(layout)), + ); } } @@ -59,21 +56,23 @@ impl TextSelectionCtx<'_> { /// the same widget-local coordinates as a `CursorSense` event. pub fn byte_at(&mut self, pos: Vec2, size: Vec2) -> usize { let pos = pos - self.view.region().top_left().to_abs(size); - let layout = self.layout(); - ParleySelection::from_point(layout, pos.x, pos.y) - .focus() - .index() + self.view.text.with_layout(|layout, _| { + ParleySelection::from_point(layout, pos.x, pos.y) + .focus() + .index() + }) } pub fn select_all(&mut self) { - let len = self.view.buf.text().len(); + let len = self.view.text.text().len(); if len == 0 { return; } - let layout = self.layout(); - let anchor = parley::Cursor::from_byte_index(layout, 0, Affinity::default()); - let focus = parley::Cursor::from_byte_index(layout, len, Affinity::default()); - self.view.selection.range = Some(ParleySelection::new(anchor, focus)); + self.view.selection.range = Some(self.view.text.with_layout(|layout, _| { + let anchor = parley::Cursor::from_byte_index(layout, 0, Affinity::default()); + let focus = parley::Cursor::from_byte_index(layout, len, Affinity::default()); + ParleySelection::new(anchor, focus) + })); } pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) { @@ -81,8 +80,7 @@ impl TextSelectionCtx<'_> { let previous = self.view.selection.range; let previous_hit = self.view.selection.double_hit; - let outcome = { - let layout = self.layout(); + let outcome = self.view.text.with_layout(|layout, _| { if drag { previous.map(|selection| { ( @@ -109,7 +107,7 @@ impl TextSelectionCtx<'_> { (Some(hit), None) }) } - }; + }); if let Some((range, double_hit)) = outcome { self.view.selection.range = range; @@ -122,39 +120,28 @@ impl TextSelectionCtx<'_> { } pub(crate) fn set_caret(&mut self, index: usize) { - let index = index.min(self.view.buf.text().len()); - let layout = self.layout(); - self.view.selection.range = Some(ParleySelection::from_byte_index( - layout, - index, - Affinity::default(), - )); + let index = index.min(self.view.text.text().len()); + self.view.selection.range = Some(self.view.text.with_layout(|layout, _| { + ParleySelection::from_byte_index(layout, index, Affinity::default()) + })); } fn select_between(&mut self, anchor: usize, focus: usize) { - let len = self.view.buf.text().len(); - let layout = self.layout(); - let anchor = parley::Cursor::from_byte_index(layout, anchor.min(len), Affinity::default()); - let focus = parley::Cursor::from_byte_index(layout, focus.min(len), Affinity::default()); - self.view.selection.range = Some(ParleySelection::new(anchor, focus)); + let len = self.view.text.text().len(); + self.view.selection.range = Some(self.view.text.with_layout(|layout, _| { + let anchor = + parley::Cursor::from_byte_index(layout, anchor.min(len), Affinity::default()); + let focus = + parley::Cursor::from_byte_index(layout, focus.min(len), Affinity::default()); + ParleySelection::new(anchor, focus) + })); } } -pub(super) fn selection_layout<'a>( - view: &'a mut TextView, - data: &mut TextData, -) -> &'a Layout { - let attrs = view.attrs.clone(); - let width = view.wrap_width(); - let density = data.density; - view.buf.shape(data, &attrs, width, density); - view.buf.layout() -} - /// Gives an ordinary `Text` handle access to the same selection operations as -/// `TextEditCtx`. Gesture policy is intentionally not part of this trait; a -/// selection controller and an editor's focus handler do different -/// things with the same mechanics. +/// `TextEdit`. Gesture policy is intentionally not part of this trait; a +/// selection controller and an editor's focus handler do different things +/// with the same mechanics. pub trait TextSelectable { fn selection<'a>(&self, ui: &'a mut impl UiRsc) -> TextSelectionCtx<'a>; } @@ -164,7 +151,6 @@ impl> TextSelectable for I { let ui: &mut UiData = ui.ui_mut(); TextSelectionCtx { view: &mut ui.widgets.get_mut(self).unwrap().view, - data: &mut ui.text, } } } @@ -256,10 +242,8 @@ impl SelectionController { .get_dyn_mut(id)? .as_any_mut() .downcast_mut::()?; - text.update_buf(); let mut ctx = TextSelectionCtx { view: &mut text.view, - data: &mut ui.text, }; Some(f(&mut ctx)) } @@ -334,7 +318,7 @@ impl SelectionController { for &text_id in &self.order[lo..=hi] { let forward = anchor_at <= focus_at; Self::with_text(rsc, text_id, |text| { - let len = text.view.buf.text().len(); + let len = text.view.text.text().len(); let (start, end) = if text_id == anchor && text_id == id { (anchor_byte, focus_byte) } else if text_id == anchor {