1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-31 22:00:03 -04:00

New text layout (#682)

This PR introduces a completely rewritten text layout engine which is simpler and more powerful. It allows mixing different text styles (heading, body, etc) and formats (color, underlining, strikethrough, …) in the same layout pass, and baked into the same `Galley`.

This opens up the door to having a syntax-highlighed code editor, or a WYSIWYG markdown editor.

One major change is the color is now baked in at layout time. However, many widgets changes text color on hovered. But we need to do the text layout before we know if it is hovered. Therefor the painter has an option to override the text color of a galley.


## Performance
Text layout alone is about 20% slower, but a lot of that is because more tessellation is done upfront. Text tessellation is now a lot faster, but text layout + tessellation still lands at a net loss of 5-10% in performance. There are however a few tricks to speed it up (like using `smallvec`) which I am saving for later. Text layout is also cached, meaning that in most cases (when all text isn't changing each frame) text tessellation is actually more important (and that's more than 2x faster!).

Sadly, the actual text cache lookup is significantly slower (300ns -> 600ns). That's because the `TextLayoutJob` is a lot bigger (it has more options, like underlining, fonts etc), so it is slower to hash and compare. I have an idea how to speed this up, but I need to do some other work before I can implement that.

All in all, the performance impact on `demo_with_tesselate__realistic` is about 5-6% in the red. Not great; not terrible. The benefits are worth it, but I also think with some work I can get that down significantly, hopefully down to the old levels.
This commit is contained in:
Emil Ernerfeldt
2021-09-03 18:18:00 +02:00
committed by GitHub
parent 36cffd7b84
commit de1a1ba9b2
43 changed files with 2204 additions and 1295 deletions

View File

@@ -0,0 +1,357 @@
use egui::text::LayoutJob;
#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "persistence", serde(default))]
pub struct CodeEditor {
code: String,
language: String,
#[cfg_attr(feature = "persistence", serde(skip))]
highlighter: MemoizedSyntaxHighlighter,
}
impl Default for CodeEditor {
fn default() -> Self {
Self {
code: "// A very simple example\n\
fn main() {\n\
\tprintln!(\"Hello world!\");\n\
}\n\
"
.into(),
language: "rs".into(),
highlighter: Default::default(),
}
}
}
impl super::Demo for CodeEditor {
fn name(&self) -> &'static str {
"🖮 Code Editor"
}
fn show(&mut self, ctx: &egui::CtxRef, open: &mut bool) {
use super::View;
egui::Window::new(self.name())
.open(open)
.show(ctx, |ui| self.ui(ui));
}
}
impl super::View for CodeEditor {
fn ui(&mut self, ui: &mut egui::Ui) {
let Self {
code,
language,
highlighter,
} = self;
ui.horizontal(|ui| {
ui.set_height(0.0);
ui.label("An example of syntax highlighting in a TextEdit.");
ui.add(crate::__egui_github_link_file!());
});
if cfg!(feature = "syntect") {
ui.horizontal(|ui| {
ui.label("Language:");
ui.text_edit_singleline(language);
});
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.label("Syntax highlighting powered by ");
ui.hyperlink_to("syntect", "https://github.com/trishume/syntect");
ui.label(".");
});
} else {
ui.horizontal_wrapped(|ui|{
ui.spacing_mut().item_spacing.x = 0.0;
ui.label("Compile the demo with the 'syntect' feature to enable much nicer syntax highlighting using ");
ui.hyperlink_to("syntect", "https://github.com/trishume/syntect");
ui.label(".");
});
}
let mut layouter = |ui: &egui::Ui, string: &str, wrap_width: f32| {
let mut layout_job = highlighter.highlight(ui.visuals().dark_mode, string, language);
layout_job.wrap_width = wrap_width;
ui.fonts().layout_job(layout_job)
};
egui::ScrollArea::vertical().show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(code)
.text_style(egui::TextStyle::Monospace) // for cursor height
.code_editor()
.lock_focus(true)
.desired_width(f32::INFINITY)
.layouter(&mut layouter),
);
});
}
}
// ----------------------------------------------------------------------------
#[derive(Default)]
struct MemoizedSyntaxHighlighter {
is_dark_mode: bool,
code: String,
language: String,
output: LayoutJob,
highligher: Highligher,
}
impl MemoizedSyntaxHighlighter {
fn highlight(&mut self, is_dark_mode: bool, code: &str, language: &str) -> LayoutJob {
if (
self.is_dark_mode,
self.code.as_str(),
self.language.as_str(),
) != (is_dark_mode, code, language)
{
self.is_dark_mode = is_dark_mode;
self.code = code.to_owned();
self.language = language.to_owned();
self.output = self
.highligher
.highlight(is_dark_mode, code, language)
.unwrap_or_else(|| {
LayoutJob::simple(
code.into(),
egui::TextStyle::Monospace,
if is_dark_mode {
egui::Color32::LIGHT_GRAY
} else {
egui::Color32::DARK_GRAY
},
f32::INFINITY,
)
});
}
self.output.clone()
}
}
// ----------------------------------------------------------------------------
#[cfg(feature = "syntect")]
struct Highligher {
ps: syntect::parsing::SyntaxSet,
ts: syntect::highlighting::ThemeSet,
}
#[cfg(feature = "syntect")]
impl Default for Highligher {
fn default() -> Self {
Self {
ps: syntect::parsing::SyntaxSet::load_defaults_newlines(),
ts: syntect::highlighting::ThemeSet::load_defaults(),
}
}
}
#[cfg(feature = "syntect")]
impl Highligher {
fn highlight(&self, is_dark_mode: bool, text: &str, language: &str) -> Option<LayoutJob> {
use syntect::easy::HighlightLines;
use syntect::highlighting::FontStyle;
use syntect::util::LinesWithEndings;
let syntax = self
.ps
.find_syntax_by_name(language)
.or_else(|| self.ps.find_syntax_by_extension(language))?;
let theme = if is_dark_mode {
"base16-mocha.dark"
} else {
"base16-ocean.light"
};
let mut h = HighlightLines::new(syntax, &self.ts.themes[theme]);
use egui::text::{LayoutSection, TextFormat};
let mut job = LayoutJob {
text: text.into(),
..Default::default()
};
for line in LinesWithEndings::from(text) {
for (style, range) in h.highlight(line, &self.ps) {
let fg = style.foreground;
let text_color = egui::Color32::from_rgb(fg.r, fg.g, fg.b);
let italics = style.font_style.contains(FontStyle::ITALIC);
let underline = style.font_style.contains(FontStyle::ITALIC);
let underline = if underline {
egui::Stroke::new(1.0, text_color)
} else {
egui::Stroke::none()
};
job.sections.push(LayoutSection {
leading_space: 0.0,
byte_range: as_byte_range(text, range),
format: TextFormat {
style: egui::TextStyle::Monospace,
color: text_color,
italics,
underline,
..Default::default()
},
});
}
}
Some(job)
}
}
#[cfg(feature = "syntect")]
fn as_byte_range(whole: &str, range: &str) -> std::ops::Range<usize> {
let whole_start = whole.as_ptr() as usize;
let range_start = range.as_ptr() as usize;
assert!(whole_start <= range_start);
assert!(range_start + range.len() <= whole_start + whole.len());
let offset = range_start - whole_start;
offset..(offset + range.len())
}
// ----------------------------------------------------------------------------
#[cfg(not(feature = "syntect"))]
#[derive(Default)]
struct Highligher {}
#[cfg(not(feature = "syntect"))]
impl Highligher {
fn highlight(&self, is_dark_mode: bool, mut text: &str, _language: &str) -> Option<LayoutJob> {
// Extremely simple syntax highlighter for when we compile without syntect
use egui::text::TextFormat;
use egui::Color32;
let monospace = egui::TextStyle::Monospace;
let comment_format = TextFormat::simple(monospace, Color32::GRAY);
let quoted_string_format = TextFormat::simple(
monospace,
if is_dark_mode {
Color32::KHAKI
} else {
Color32::BROWN
},
);
let keyword_format = TextFormat::simple(
monospace,
if is_dark_mode {
Color32::LIGHT_RED
} else {
Color32::DARK_RED
},
);
let literal_format = TextFormat::simple(
monospace,
if is_dark_mode {
Color32::LIGHT_GREEN
} else {
Color32::DARK_GREEN
},
);
let whitespace_format = TextFormat::simple(monospace, Color32::WHITE);
let punctuation_format = TextFormat::simple(
monospace,
if is_dark_mode {
Color32::LIGHT_GRAY
} else {
Color32::DARK_GRAY
},
);
let mut job = LayoutJob::default();
while !text.is_empty() {
if text.starts_with("//") {
let end = text.find('\n').unwrap_or(text.len());
job.append(&text[..end], 0.0, comment_format);
text = &text[end..];
} else if text.starts_with('"') {
let end = text[1..]
.find('"')
.map(|i| i + 2)
.or_else(|| text.find('\n'))
.unwrap_or(text.len());
job.append(&text[..end], 0.0, quoted_string_format);
text = &text[end..];
} else if text.starts_with(|c: char| c.is_ascii_alphanumeric()) {
let end = text[1..]
.find(|c: char| !c.is_ascii_alphanumeric())
.map(|i| i + 1)
.unwrap_or(text.len());
let word = &text[..end];
if is_keyword(word) {
job.append(word, 0.0, keyword_format);
} else {
job.append(word, 0.0, literal_format);
};
text = &text[end..];
} else if text.starts_with(|c: char| c.is_ascii_whitespace()) {
let end = text[1..]
.find(|c: char| !c.is_ascii_whitespace())
.map(|i| i + 1)
.unwrap_or(text.len());
job.append(&text[..end], 0.0, whitespace_format);
text = &text[end..];
} else {
let mut it = text.char_indices();
it.next();
let end = it.next().map_or(text.len(), |(idx, _chr)| idx);
job.append(&text[..end], 0.0, punctuation_format);
text = &text[end..];
}
}
Some(job)
}
}
#[cfg(not(feature = "syntect"))]
fn is_keyword(word: &str) -> bool {
matches!(
word,
"as" | "async"
| "await"
| "break"
| "const"
| "continue"
| "crate"
| "dyn"
| "else"
| "enum"
| "extern"
| "false"
| "fn"
| "for"
| "if"
| "impl"
| "in"
| "let"
| "loop"
| "match"
| "mod"
| "move"
| "mut"
| "pub"
| "ref"
| "return"
| "self"
| "Self"
| "static"
| "struct"
| "super"
| "trait"
| "true"
| "type"
| "unsafe"
| "use"
| "where"
| "while"
)
}

View File

@@ -16,6 +16,7 @@ struct Demos {
impl Default for Demos {
fn default() -> Self {
Self::from_demos(vec![
Box::new(super::code_editor::CodeEditor::default()),
Box::new(super::dancing_strings::DancingStrings::default()),
Box::new(super::drag_and_drop::DragAndDropDemo::default()),
Box::new(super::font_book::FontBook::default()),

View File

@@ -50,6 +50,12 @@ impl View for MiscDemoWindow {
self.widgets.ui(ui);
});
CollapsingHeader::new("Text layout")
.default_open(false)
.show(ui, |ui| {
text_layout_ui(ui);
});
CollapsingHeader::new("Colors")
.default_open(false)
.show(ui, |ui| {
@@ -114,8 +120,6 @@ impl View for MiscDemoWindow {
pub struct Widgets {
angle: f32,
password: String,
lock_focus: bool,
code_snippet: String,
}
impl Default for Widgets {
@@ -123,25 +127,13 @@ impl Default for Widgets {
Self {
angle: std::f32::consts::TAU / 3.0,
password: "hunter2".to_owned(),
lock_focus: true,
code_snippet: "\
fn main() {
\tprintln!(\"Hello world!\");
}
"
.to_owned(),
}
}
}
impl Widgets {
pub fn ui(&mut self, ui: &mut Ui) {
let Self {
angle,
password,
lock_focus,
code_snippet,
} = self;
let Self { angle, password } = self;
ui.vertical_centered(|ui| {
ui.add(crate::__egui_github_link_file_line!());
});
@@ -196,24 +188,6 @@ impl Widgets {
.on_hover_text("See the example code for how to use egui to store UI state");
ui.add(super::password::password(password));
});
ui.separator();
ui.horizontal(|ui| {
ui.label("Code editor:");
ui.separator();
ui.checkbox(lock_focus, "Lock focus").on_hover_text(
"When checked, pressing TAB will insert a tab instead of moving focus",
);
});
ui.add(
TextEdit::multiline(code_snippet)
.code_editor()
.lock_focus(*lock_focus),
);
}
}
@@ -423,3 +397,182 @@ impl SubTree {
Action::Keep
}
}
// ----------------------------------------------------------------------------
fn text_layout_ui(ui: &mut egui::Ui) {
use egui::epaint::text::{LayoutJob, TextFormat};
let mut job = LayoutJob::default();
let first_row_indentation = 10.0;
let (default_color, strong_color) = if ui.visuals().dark_mode {
(Color32::LIGHT_GRAY, Color32::WHITE)
} else {
(Color32::DARK_GRAY, Color32::BLACK)
};
job.append(
"This is a demonstration of ",
first_row_indentation,
TextFormat {
style: TextStyle::Body,
color: default_color,
..Default::default()
},
);
job.append(
"the egui text layout engine. ",
0.0,
TextFormat {
style: TextStyle::Body,
color: strong_color,
..Default::default()
},
);
job.append(
"It supports ",
0.0,
TextFormat {
style: TextStyle::Body,
color: default_color,
..Default::default()
},
);
job.append(
"different ",
0.0,
TextFormat {
style: TextStyle::Body,
color: Color32::from_rgb(110, 255, 110),
..Default::default()
},
);
job.append(
"colors, ",
0.0,
TextFormat {
style: TextStyle::Body,
color: Color32::from_rgb(128, 140, 255),
..Default::default()
},
);
job.append(
"backgrounds, ",
0.0,
TextFormat {
style: TextStyle::Body,
color: default_color,
background: Color32::from_rgb(128, 32, 32),
..Default::default()
},
);
job.append(
"mixing ",
0.0,
TextFormat {
style: TextStyle::Heading,
color: default_color,
..Default::default()
},
);
job.append(
"fonts, ",
0.0,
TextFormat {
style: TextStyle::Monospace,
color: default_color,
..Default::default()
},
);
job.append(
"raised text, ",
0.0,
TextFormat {
style: TextStyle::Small,
color: default_color,
valign: Align::TOP,
..Default::default()
},
);
job.append(
"with ",
0.0,
TextFormat {
style: TextStyle::Body,
color: default_color,
..Default::default()
},
);
job.append(
"underlining",
0.0,
TextFormat {
style: TextStyle::Body,
color: default_color,
underline: Stroke::new(1.0, Color32::LIGHT_BLUE),
..Default::default()
},
);
job.append(
" and ",
0.0,
TextFormat {
style: TextStyle::Body,
color: default_color,
..Default::default()
},
);
job.append(
"strikethrough",
0.0,
TextFormat {
style: TextStyle::Body,
color: default_color,
strikethrough: Stroke::new(2.0, Color32::RED.linear_multiply(0.5)),
..Default::default()
},
);
job.append(
". Of course, ",
0.0,
TextFormat {
style: TextStyle::Body,
color: default_color,
..Default::default()
},
);
job.append(
"you can",
0.0,
TextFormat {
style: TextStyle::Body,
color: default_color,
strikethrough: Stroke::new(1.0, strong_color),
..Default::default()
},
);
job.append(
" mix these!",
0.0,
TextFormat {
style: TextStyle::Small,
color: Color32::LIGHT_BLUE,
background: Color32::from_rgb(128, 0, 0),
underline: Stroke::new(1.0, strong_color),
..Default::default()
},
);
job.wrap_width = ui.available_width();
let galley = ui.fonts().layout_job(job);
let (response, painter) = ui.allocate_painter(galley.size, Sense::hover());
painter.add(Shape::galley(response.rect.min, galley));
ui.vertical_centered(|ui| {
ui.add(crate::__egui_github_link_file_line!());
});
}

View File

@@ -5,6 +5,7 @@
// ----------------------------------------------------------------------------
mod app;
pub mod code_editor;
pub mod dancing_strings;
pub mod demo_app_windows;
pub mod drag_and_drop;

View File

@@ -292,13 +292,14 @@ fn syntax_highlighting(response: &Response, text: &str) -> Option<ColoredText> {
/// Lines of text fragments
#[cfg(feature = "syntect")]
struct ColoredText(Vec<Vec<(syntect::highlighting::Style, String)>>);
struct ColoredText(egui::text::LayoutJob);
#[cfg(feature = "syntect")]
impl ColoredText {
/// e.g. `text_with_extension("fn foo() {}", "rs")`
pub fn text_with_extension(text: &str, extension: &str) -> Option<ColoredText> {
use syntect::easy::HighlightLines;
use syntect::highlighting::FontStyle;
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
@@ -308,36 +309,67 @@ impl ColoredText {
let syntax = ps.find_syntax_by_extension(extension)?;
let mut h = HighlightLines::new(syntax, &ts.themes["base16-mocha.dark"]);
let dark_mode = true;
let theme = if dark_mode {
"base16-mocha.dark"
} else {
"base16-ocean.light"
};
let mut h = HighlightLines::new(syntax, &ts.themes[theme]);
let lines = LinesWithEndings::from(text)
.map(|line| {
h.highlight(line, &ps)
.into_iter()
.map(|(style, range)| (style, range.trim_end_matches('\n').to_owned()))
.collect()
})
.collect();
use egui::text::{LayoutJob, LayoutSection, TextFormat};
Some(ColoredText(lines))
let mut job = LayoutJob {
text: text.into(),
..Default::default()
};
for line in LinesWithEndings::from(text) {
for (style, range) in h.highlight(line, &ps) {
let fg = style.foreground;
let text_color = egui::Color32::from_rgb(fg.r, fg.g, fg.b);
let italics = style.font_style.contains(FontStyle::ITALIC);
let underline = style.font_style.contains(FontStyle::ITALIC);
let underline = if underline {
egui::Stroke::new(1.0, text_color)
} else {
egui::Stroke::none()
};
job.sections.push(LayoutSection {
leading_space: 0.0,
byte_range: as_byte_range(text, range),
format: TextFormat {
style: egui::TextStyle::Monospace,
color: text_color,
italics,
underline,
..Default::default()
},
});
}
}
Some(ColoredText(job))
}
pub fn ui(&self, ui: &mut egui::Ui) {
for line in &self.0 {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::Vec2::ZERO;
ui.set_row_height(ui.fonts()[egui::TextStyle::Body].row_height());
for (style, range) in line {
let fg = style.foreground;
let text_color = egui::Color32::from_rgb(fg.r, fg.g, fg.b);
ui.add(egui::Label::new(range).monospace().text_color(text_color));
}
});
}
let mut job = self.0.clone();
job.wrap_width = ui.available_width();
let galley = ui.fonts().layout_job(job);
let (response, painter) = ui.allocate_painter(galley.size, egui::Sense::hover());
painter.add(egui::Shape::galley(response.rect.min, galley));
}
}
fn as_byte_range(whole: &str, range: &str) -> std::ops::Range<usize> {
let whole_start = whole.as_ptr() as usize;
let range_start = range.as_ptr() as usize;
assert!(whole_start <= range_start);
assert!(range_start + range.len() <= whole_start + whole.len());
let offset = range_start - whole_start;
offset..(offset + range.len())
}
#[cfg(not(feature = "syntect"))]
fn syntax_highlighting(_: &Response, _: &str) -> Option<ColoredText> {
None

View File

@@ -45,7 +45,7 @@ pub struct Style {
pub strong: bool,
/// _underline_
pub underline: bool,
/// -strikethrough-
/// ~strikethrough~
pub strikethrough: bool,
/// /italics/
pub italics: bool,
@@ -67,7 +67,7 @@ pub struct Style {
///
/// ```
pub struct Parser<'a> {
/// The remainer of the input text
/// The remainder of the input text
s: &'a str,
/// Are we at the start of a line?
start_of_line: bool,

View File

@@ -7,7 +7,16 @@ pub fn easy_mark(ui: &mut Ui, easy_mark: &str) {
}
pub fn easy_mark_it<'em>(ui: &mut Ui, items: impl Iterator<Item = easy_mark::Item<'em>>) {
ui.horizontal_wrapped(|ui| {
let initial_size = vec2(
ui.available_width(),
ui.spacing().interact_size.y, // Assume there will be
);
let layout = Layout::left_to_right()
.with_main_wrap(true)
.with_cross_align(Align::BOTTOM);
ui.allocate_ui_with_layout(initial_size, layout, |ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.set_row_height(ui.fonts()[TextStyle::Body].row_height());