iris: move text state into shared resources

This commit is contained in:
iris committed 2026-09-12 14:18:12 -04:00
1 parent 7c23c5f146
commit 51719ed121
17 files changed
+626 -429

No files matched your search

+5 -6
View File
@@ -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();
+289 -24
View File
@@ -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<String, String>,
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<f32>,
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<RenderedText>,
width: Option<f32>,
density: f32,
owner: Option<WidgetId>,
}
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<TextResource>,
send: Sender<SlotId>,
recv: Receiver<SlotId>,
}
impl TextResources {
pub fn new() -> Self {
let (send, recv) = channel();
Self {
data: TextData::default(),
entries: SlotVec::new(),
send,
recv,
}
}
pub fn add(resources: Rc<RefCell<Self>>, 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<WidgetId> {
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<f32>,
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<RefCell<TextResources>>,
send: Sender<SlotId>,
}
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<String>) -> 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<R>(&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<SpanStyle>) {
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<R>(&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<RenderedText> {
self.resources.borrow().entry(self.id).rendered.clone()
}
pub fn width(&self) -> Option<f32> {
self.resources.borrow().entry(self.id).width
}
pub fn with_layout<R>(&self, f: impl FnOnce(&Layout<PaintId>, &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<PaintId>> {
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<f32>,
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());
}
}
+22 -9
View File
@@ -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<RefCell<TextResources>>,
pub masks: TrackedArena<Mask, u32>,
pub move_offsets: TrackedArena<MoveOffset, u32>,
animating: Vec<WidgetId>,
@@ -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<str>,
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<WidgetId> = {
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<str>) -> 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();
}
}
+11 -15
View File
@@ -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<f32>,
) -> RenderedText {
pub fn render_text(&mut self, text: &TextHandle, width: Option<f32>) -> 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<RefCell<TextResources>> {
self.rsc.ui().text.clone()
}
pub fn child_layer(&mut self) {
+4
View File
@@ -71,6 +71,10 @@ impl<T> SlotVec<T> {
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut T> {
self.data.iter_mut().filter_map(|(_, value)| value.as_mut())
}
}
impl<T> Default for SlotVec<T> {
+3 -3
View File
@@ -8,8 +8,8 @@ pub(crate) fn update_info<Rsc: UiRsc>(rsc: &mut Rsc, info: WeakWidget<Text>, 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::<Selectable>(())
.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)
+1 -1
View File
@@ -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));
});
```
+30 -29
View File
@@ -43,11 +43,11 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
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<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
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<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
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<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
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<Cow<'slf, str>> {
@@ -151,8 +149,9 @@ impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
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<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
};
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<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
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<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
};
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
+1 -1
View File
@@ -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),
+8 -8
View File
@@ -217,7 +217,7 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
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<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
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<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
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<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
"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<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
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;
}
+3 -3
View File
@@ -250,7 +250,7 @@ impl<State: DesktopAppState> AppState for DesktopApp<State> {
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<State: DesktopAppState> AppState for DesktopApp<State> {
&& 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<State: DesktopAppState> AppState for DesktopApp<State> {
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) => {
+3 -5
View File
@@ -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();
+14 -14
View File
@@ -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));
}
+9 -7
View File
@@ -91,12 +91,10 @@ impl<Rsc: UiRsc> TextBuilderOutput<Rsc> 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<State: UiRsc> TextBuilderOutput<State> 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,
)
}
+132 -148
View File
@@ -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<String> {
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<iris_core::PaintId> {
selection_layout(&mut self.text.view, self.data)
fn layout(&self) -> Ref<'_, Layout<iris_core::PaintId>> {
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<SpanStyle>) {
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<I: IdLike<Widget = TextEdit>> 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("", EditMode::SingleLine);
ctx(&mut t, &mut d).set_caret(3);
ctx(&mut t, &mut d).backspace(false);
let mut t = edit("", EditMode::SingleLine);
ctx(&mut t).set_caret(3);
ctx(&mut t).backspace(false);
assert_eq!(content(&t), "a");
}
}
+58 -107
View File
@@ -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<String>,
view: TextView,
}
pub struct TextView {
pub attrs: MutDetect<TextAttrs>,
pub buf: MutDetect<TextBuffer>,
tex: Option<RenderedText>,
width: Option<f32>,
text: TextHandle,
pub hint: Option<StrongWidget>,
selection: TextSelection,
}
impl TextView {
fn is_blank(&self) -> bool {
self.buf.is_empty()
self.text.text().is_empty()
}
pub fn wrap_width(&self) -> Option<f32> {
self.width
self.text.width()
}
pub fn new(buf: TextBuffer, attrs: TextAttrs, hint: Option<StrongWidget>) -> Self {
pub fn new(text: TextHandle, hint: Option<StrongWidget>) -> 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<R>(&mut self, update: impl FnOnce(&mut TextAttrs) -> R) -> R {
self.text.update_attrs(update)
}
}
impl Text {
pub fn new(content: impl Into<String>) -> 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<String>) {
if self.view.text.set_text(content) {
self.view.selection.deselect();
}
}
pub fn selected_text(&self) -> Option<String> {
self.view.selection.selected_text(self.view.buf.text())
self.view.selection.selected_text(&self.view.text.text())
}
pub fn selection_range(&self) -> Option<std::ops::Range<usize>> {
@@ -161,18 +138,22 @@ impl Text {
}
pub fn set_with_spans(&mut self, content: impl Into<String>, spans: Vec<SpanStyle>) {
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<R>(&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"
+33 -49
View File
@@ -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<PaintId> {
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();
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();
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());
self.view.selection.range = Some(ParleySelection::new(anchor, focus));
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<PaintId> {
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<I: IdLike<Widget = Text>> 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>()?;
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 {