Use `TextBuffer` for `layouter` in `TextEdit` instead of `&str` (#5712)

This change allows `layouter` to use the `TextBuffer` instead of `&str`
in the closure. It is necessary when layout decisions depend on more
than just the raw string content, such as metadata stored in the
concrete type implementing `TextBuffer`.

In [our use case](https://github.com/damus-io/notedeck/pull/723), we
needed this to support mention highlighting when a user selects a
mention. Since mentions can contain spaces, determining mention
boundaries from the `&str` alone is impossible. Instead, we use the
`TextBuffer` implementation to retrieve the correct bounds.

See the video below for a demonstration:


https://github.com/user-attachments/assets/3cba2906-5546-4b52-b728-1da9c56a83e1


# Breaking change

This PR introduces a breaking change to the `layouter` function in
`TextEdit`.

Previous API:
```rust
pub fn layouter(mut self, layouter: &'t mut dyn FnMut(&Ui, &str, f32) -> Arc<Galley>) -> Self
```

New API:
```rust
pub fn layouter(mut self, layouter: &'t mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc<Galley>) -> Self
```


## Impact on Existing Code

• Any existing usage of `layouter` will **no longer compile**.
• Callers must update their closures to use `&dyn TextBuffer` instead of
`&str`.

## Migration Guide

Before:
```rust
let mut layouter = |ui: &Ui, text: &str, wrap_width: f32| {
    let layout_job = my_highlighter(text);
    layout_job.wrap.max_width = wrap_width;
    ui.fonts(|f| f.layout_job(layout_job))
};
```

After:
```rust
let mut layouter = |ui: &Ui, text: &dyn TextBuffer, wrap_width: f32| {
    let layout_job = my_highlighter(text.as_str());
    layout_job.wrap.max_width = wrap_width;
    ui.fonts(|f| f.layout_job(layout_job))
};
```

---

* There is not an issue for this change.
* [x]  I have followed the instructions in the PR template

Signed-off-by: kernelkind <kernelkind@gmail.com>
This commit is contained in:
kernelkind 2025-04-06 12:45:20 -04:00 committed by GitHub
parent d78fc39386
commit fe631ff9ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 63 additions and 13 deletions

View File

@ -19,6 +19,8 @@ use crate::{
use super::{TextEditOutput, TextEditState}; use super::{TextEditOutput, TextEditState};
type LayouterFn<'t> = &'t mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc<Galley>;
/// A text region that the user can edit the contents of. /// A text region that the user can edit the contents of.
/// ///
/// See also [`Ui::text_edit_singleline`] and [`Ui::text_edit_multiline`]. /// See also [`Ui::text_edit_singleline`] and [`Ui::text_edit_multiline`].
@ -71,7 +73,7 @@ pub struct TextEdit<'t> {
id_salt: Option<Id>, id_salt: Option<Id>,
font_selection: FontSelection, font_selection: FontSelection,
text_color: Option<Color32>, text_color: Option<Color32>,
layouter: Option<&'t mut dyn FnMut(&Ui, &str, f32) -> Arc<Galley>>, layouter: Option<LayouterFn<'t>>,
password: bool, password: bool,
frame: bool, frame: bool,
margin: Margin, margin: Margin,
@ -261,8 +263,8 @@ impl<'t> TextEdit<'t> {
/// # egui::__run_test_ui(|ui| { /// # egui::__run_test_ui(|ui| {
/// # let mut my_code = String::new(); /// # let mut my_code = String::new();
/// # fn my_memoized_highlighter(s: &str) -> egui::text::LayoutJob { Default::default() } /// # fn my_memoized_highlighter(s: &str) -> egui::text::LayoutJob { Default::default() }
/// let mut layouter = |ui: &egui::Ui, string: &str, wrap_width: f32| { /// let mut layouter = |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
/// let mut layout_job: egui::text::LayoutJob = my_memoized_highlighter(string); /// let mut layout_job: egui::text::LayoutJob = my_memoized_highlighter(buf.as_str());
/// layout_job.wrap.max_width = wrap_width; /// layout_job.wrap.max_width = wrap_width;
/// ui.fonts(|f| f.layout_job(layout_job)) /// ui.fonts(|f| f.layout_job(layout_job))
/// }; /// };
@ -270,7 +272,10 @@ impl<'t> TextEdit<'t> {
/// # }); /// # });
/// ``` /// ```
#[inline] #[inline]
pub fn layouter(mut self, layouter: &'t mut dyn FnMut(&Ui, &str, f32) -> Arc<Galley>) -> Self { pub fn layouter(
mut self,
layouter: &'t mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc<Galley>,
) -> Self {
self.layouter = Some(layouter); self.layouter = Some(layouter);
self self
@ -510,8 +515,8 @@ impl TextEdit<'_> {
}; };
let font_id_clone = font_id.clone(); let font_id_clone = font_id.clone();
let mut default_layouter = move |ui: &Ui, text: &str, wrap_width: f32| { let mut default_layouter = move |ui: &Ui, text: &dyn TextBuffer, wrap_width: f32| {
let text = mask_if_password(password, text); let text = mask_if_password(password, text.as_str());
let layout_job = if multiline { let layout_job = if multiline {
LayoutJob::simple(text, font_id_clone.clone(), text_color, wrap_width) LayoutJob::simple(text, font_id_clone.clone(), text_color, wrap_width)
} else { } else {
@ -522,7 +527,7 @@ impl TextEdit<'_> {
let layouter = layouter.unwrap_or(&mut default_layouter); let layouter = layouter.unwrap_or(&mut default_layouter);
let mut galley = layouter(ui, text.as_str(), wrap_width); let mut galley = layouter(ui, text, wrap_width);
let desired_inner_width = if clip_text { let desired_inner_width = if clip_text {
wrap_width // visual clipping with scroll in singleline input. wrap_width // visual clipping with scroll in singleline input.
@ -879,7 +884,7 @@ fn events(
state: &mut TextEditState, state: &mut TextEditState,
text: &mut dyn TextBuffer, text: &mut dyn TextBuffer,
galley: &mut Arc<Galley>, galley: &mut Arc<Galley>,
layouter: &mut dyn FnMut(&Ui, &str, f32) -> Arc<Galley>, layouter: &mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc<Galley>,
id: Id, id: Id,
wrap_width: f32, wrap_width: f32,
multiline: bool, multiline: bool,
@ -1094,7 +1099,7 @@ fn events(
any_change = true; any_change = true;
// Layout again to avoid frame delay, and to keep `text` and `galley` in sync. // Layout again to avoid frame delay, and to keep `text` and `galley` in sync.
*galley = layouter(ui, text.as_str(), wrap_width); *galley = layouter(ui, text, wrap_width);
// Set cursor_range using new galley: // Set cursor_range using new galley:
cursor_range = new_ccursor_range; cursor_range = new_ccursor_range;

View File

@ -172,6 +172,39 @@ pub trait TextBuffer {
self.delete_selected(&CCursorRange::two(min, max)) self.delete_selected(&CCursorRange::two(min, max))
} }
} }
/// Returns a unique identifier for the implementing type.
///
/// This is useful for downcasting from this trait to the implementing type.
/// Here is an example usage:
/// ```
/// use egui::TextBuffer;
/// use std::any::TypeId;
///
/// struct ExampleBuffer {}
///
/// impl TextBuffer for ExampleBuffer {
/// fn is_mutable(&self) -> bool { unimplemented!() }
/// fn as_str(&self) -> &str { unimplemented!() }
/// fn insert_text(&mut self, text: &str, char_index: usize) -> usize { unimplemented!() }
/// fn delete_char_range(&mut self, char_range: std::ops::Range<usize>) { unimplemented!() }
///
/// // Implement it like the following:
/// fn type_id(&self) -> TypeId {
/// TypeId::of::<Self>()
/// }
/// }
///
/// // Example downcast:
/// pub fn downcast_example(buffer: &dyn TextBuffer) -> Option<&ExampleBuffer> {
/// if buffer.type_id() == TypeId::of::<ExampleBuffer>() {
/// unsafe { Some(&*(buffer as *const dyn TextBuffer as *const ExampleBuffer)) }
/// } else {
/// None
/// }
/// }
/// ```
fn type_id(&self) -> std::any::TypeId;
} }
impl TextBuffer for String { impl TextBuffer for String {
@ -218,6 +251,10 @@ impl TextBuffer for String {
fn take(&mut self) -> String { fn take(&mut self) -> String {
std::mem::take(self) std::mem::take(self)
} }
fn type_id(&self) -> std::any::TypeId {
std::any::TypeId::of::<Self>()
}
} }
impl TextBuffer for Cow<'_, str> { impl TextBuffer for Cow<'_, str> {
@ -248,6 +285,10 @@ impl TextBuffer for Cow<'_, str> {
fn take(&mut self) -> String { fn take(&mut self) -> String {
std::mem::take(self).into_owned() std::mem::take(self).into_owned()
} }
fn type_id(&self) -> std::any::TypeId {
std::any::TypeId::of::<Cow<'_, str>>()
}
} }
/// Immutable view of a `&str`! /// Immutable view of a `&str`!
@ -265,4 +306,8 @@ impl TextBuffer for &str {
} }
fn delete_char_range(&mut self, _ch_range: Range<usize>) {} fn delete_char_range(&mut self, _ch_range: Range<usize>) {}
fn type_id(&self) -> std::any::TypeId {
std::any::TypeId::of::<&str>()
}
} }

View File

@ -76,12 +76,12 @@ impl crate::View for CodeEditor {
}); });
}); });
let mut layouter = |ui: &egui::Ui, string: &str, wrap_width: f32| { let mut layouter = |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
let mut layout_job = egui_extras::syntax_highlighting::highlight( let mut layout_job = egui_extras::syntax_highlighting::highlight(
ui.ctx(), ui.ctx(),
ui.style(), ui.style(),
&theme, &theme,
string, buf.as_str(),
language, language,
); );
layout_job.wrap.max_width = wrap_width; layout_job.wrap.max_width = wrap_width;

View File

@ -80,8 +80,8 @@ impl EasyMarkEditor {
} = self; } = self;
let response = if self.highlight_editor { let response = if self.highlight_editor {
let mut layouter = |ui: &egui::Ui, easymark: &str, wrap_width: f32| { let mut layouter = |ui: &egui::Ui, easymark: &dyn TextBuffer, wrap_width: f32| {
let mut layout_job = highlighter.highlight(ui.style(), easymark); let mut layout_job = highlighter.highlight(ui.style(), easymark.as_str());
layout_job.wrap.max_width = wrap_width; layout_job.wrap.max_width = wrap_width;
ui.fonts(|f| f.layout_job(layout_job)) ui.fonts(|f| f.layout_job(layout_job))
}; };