emilk / emilk/egui

Copy-paste works incorrectly when using a `layouter` that produces skips some parts of the text buffer

Open
#5,885 0 comments 0 reactions 0 assignees View on GitHub
bug egui
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

01:00:37PM aligned
01:00:37PM started telop
01:00:37PM interrupted
01:00:37PM aligned
01:00:37PM started telop
01:00:37PM interrupted
01:00:37PM Initial values - frequency: 5.0, y_offset: 0.0
01:00:38PM Tangent value: -2.713
01:00:39PM Tangent value: 0.755
01:00:40PM Tangent value: -0.639
01:00:41PM Tangent value: 3.903
01:00:42PM Tangent value: 0.039
01:00:43PM Tangent value: -2.626
01:00:44PM Tangent value: 0.880

```
3. Copy and paste a line containing ANSI directives
Image

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
12:51:47PM In`
5.
**Expected behavior**

**Screenshots**

Image

**Desktop (please complete the following information):**
- OS: MacOS
- Bevy
- Version

**Additional context**

Contributor guide

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.