Copy-paste works incorrectly when using a `layouter` that produces skips some parts of the text buffer
- Dominant language
- Rust
- Stars
- 30.6k
- Forks
- 2.1k
- Avg merge
- 1d 9h
- Merged PRs (30d)
- 72
Description
**Describe the bug**
I am using a custom layouter that uses `ANSI` directives to show text colors and formatting from subprocess logs. When constructing the `LayoutJob`, substrings matching a regular expression are used to set the text formatting but are not included in the final layoutjob.
rust code
```rust
ui.add_sized(
ui.available_size(),
egui::TextEdit::multiline(&mut &*content).layouter(&mut layouter),
)
fn layout_ansi(
ctx: &egui::Context,
visuals: &egui::Visuals,
style: &egui::Style,
text: &str,
wrap_width: f32,
) -> LayoutJob {
#[derive(Default)]
struct AnsiLayout;
impl egui::util::cache::ComputerMut<(&str, &egui::FontId, egui::Color32, u32), LayoutJob>
for AnsiLayout
{
fn compute(
&mut self,
(text, font_id, default_color, wrap_width_bits): (
&str,
&egui::FontId,
egui::Color32,
u32,
),
) -> LayoutJob {
layout_ansi_inner(
text,
font_id,
default_color,
f32::from_bits(wrap_width_bits),
)
}
}
type AnsiCache = egui::util::cache::FrameCache;
let font_id = egui::TextStyle::Monospace.resolve(style);
let default_color = visuals
.override_text_color
.unwrap_or_else(|| visuals.widgets.inactive.text_color());
ctx.memory_mut(|mem| {
mem.caches
.cache::()
// interpret float as bits since it must impl Hash
.get((text, &font_id, default_color, wrap_width.to_bits()))
})
}
pub const ANSI_RED: &str = "\u{001b}[31m";
pub const ANSI_RESET: &str = "\u{001b}[0m";
fn layout_ansi_inner(
text: &str,
font_id: &egui::FontId,
default_color: egui::Color32,
wrap_width: f32,
) -> LayoutJob {
let mut job = LayoutJob {
text: text.to_owned(),
wrap: egui::text::TextWrapping {
max_width: wrap_width,
..default()
},
..default()
};
struct TextAttrs {
color: egui::Color32,
italics: bool,
underline: bool,
strikethrough: bool,
}
impl TextAttrs {
fn new(color: egui::Color32) -> Self {
Self {
color,
italics: false,
underline: false,
strikethrough: false,
}
}
fn underline(&self) -> egui::Stroke {
if self.underline {
egui::Stroke::new(1.0, self.color)
} else {
egui::Stroke::NONE
}
}
fn strikethrough(&self) -> egui::Stroke {
if self.strikethrough {
egui::Stroke::new(1.0, self.color)
} else {
egui::Stroke::NONE
}
}
}
let mut text_attrs = TextAttrs::new(default_color);
let mut unread_text = text;
while let Some(captures) = ANSI_MATCHER.captures(unread_text) {
let r#match = captures.get(0).unwrap();
job.sections.push(egui::text::LayoutSection {
leading_space: 0.0,
byte_range: job.text.len()..job.text.len() + r#match.start(),
format: egui::TextFormat {
font_id: font_id.clone(),
color: text_attrs.color,
italics: text_attrs.italics,
underline: text_attrs.underline(),
strikethrough: text_attrs.strikethrough(),
..default()
},
});
for m in captures.iter().skip(1).flatten() {
match m.as_str() {
"0" => text_attrs = TextAttrs::new(default_color),
"1" => {} // bold, egui doesn't seem to support this easily
"2" => {} // faint, unimplemented
"3" => text_attrs.italics = true,
"4" => text_attrs.underline = true,
"9" => text_attrs.strikethrough = true,
"30" => text_attrs.color = egui::Color32::BLACK,
"31" => text_attrs.color = egui::Color32::RED,
"32" => text_attrs.color = egui::Color32::GREEN,
"33" => text_attrs.color = egui::Color32::YELLOW,
"34" => text_attrs.color = egui::Color32::BLUE,
"35" => text_attrs.color = egui::Color32::from_rgb(170, 0, 170), // magenta
"36" => text_attrs.color = egui::Color32::from_rgb(0, 170, 170), // cyan
"37" => text_attrs.color = egui::Color32::WHITE,
value => {
bevy::log::warn!("unknown ANSI code `{value}`")
}
}
}
job.text.push_str(&unread_text[..r#match.start()]);
unread_text = &unread_text[r#match.end()..];
if unread_text.is_empty() {
break;
}
}
if !unread_text.is_empty() {
job.sections.push(egui::text::LayoutSection {
leading_space: 0.0,
byte_range: job.text.len()..job.text.len() + unread_text.len(),
format: egui::TextFormat {
font_id: font_id.clone(),
color: text_attrs.color,
italics: text_attrs.italics,
underline: text_attrs.underline(),
strikethrough: text_attrs.strikethrough(),
..default()
},
});
job.text.push_str(unread_text);
}
job
}
pub(crate) static ANSI_MATCHER: LazyLock =
LazyLock::new(|| regex::Regex::new("\u{001b}\\[(\\d+)(?:;(\\d+))*m").expect("invalid regex"));
```
**To Reproduce**
Steps to reproduce the behavior:
1. Create an immutable multiline text editor using the layouter defined in the collapsed section above
sample ANSI outputs
```text
[32;4m01:00:37PM[0m aligned
[32;4m01:00:37PM[0m started telop
[32;4m01:00:37PM[0m interrupted
[32;4m01:00:37PM[0m aligned
[32;4m01:00:37PM[0m started telop
[32;4m01:00:37PM[0m interrupted
[32;4m01:00:37PM[0m Initial values - frequency: 5.0, y_offset: 0.0
[32;4m01:00:38PM[0m Tangent value: [31m[1m-2.713[0m
[32;4m01:00:39PM[0m Tangent value: [32m[2m0.755[0m
[32;4m01:00:40PM[0m Tangent value: [31m[1m-0.639[0m
[32;4m01:00:41PM[0m Tangent value: [32m[2m3.903[0m
[32;4m01:00:42PM[0m Tangent value: [32m[2m0.039[0m
[32;4m01:00:43PM[0m Tangent value: [31m[1m-2.626[0m
[32;4m01:00:44PM[0m Tangent value: [32m[2m0.880[0m
```
3. Copy and paste a line containing ANSI directives
4. Text will be copied from the original text buffer rather than from the `LayoutJob`'s buffer, resulting in incorrect indices and text not included in the layout job:
`rrupted
[32;4m12:51:47PM[0m In`
5.
**Expected behavior**
**Screenshots**
**Desktop (please complete the following information):**
- OS: MacOS
- Bevy
- Version
**Additional context**
Contributor guide
Research direction
Start at egui::TextEdit's layouter and the LayoutJob copy/paste handling, using the provided ANSI layouter and reproduction steps to observe the mismatched indices. Trace how copied text is selected from the original buffer versus the laid-out text. Done means copying a line with skipped ANSI directives returns the intended visible text without incorrect indices.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100