1
0
mirror of https://github.com/emilk/egui.git synced 2026-09-02 14:50: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

@@ -268,7 +268,8 @@ impl CollapsingHeader {
let available = ui.available_rect_before_wrap();
let text_pos = available.min + vec2(ui.spacing().indent, 0.0);
let galley = label.layout_width(ui, available.right() - text_pos.x);
let galley =
label.layout_width(ui, available.right() - text_pos.x, Color32::TEMPORARY_COLOR);
let text_max_x = text_pos.x + galley.size.x;
let mut desired_width = text_max_x + button_padding.x - available.left();
@@ -292,7 +293,7 @@ impl CollapsingHeader {
header_response.mark_changed();
}
header_response
.widget_info(|| WidgetInfo::labeled(WidgetType::CollapsingHeader, &galley.text));
.widget_info(|| WidgetInfo::labeled(WidgetType::CollapsingHeader, galley.text()));
let visuals = ui
.style()
@@ -337,7 +338,7 @@ impl CollapsingHeader {
paint_icon(ui, openness, &icon_response);
}
ui.painter().galley(text_pos, galley, text_color);
ui.painter().galley_with_color(text_pos, galley, text_color);
Prepared {
id,

View File

@@ -158,9 +158,9 @@ fn combo_box<R>(
let full_minimum_width = ui.spacing().slider_width;
let icon_size = Vec2::splat(ui.spacing().icon_width);
let galley = ui
.fonts()
.layout_no_wrap(TextStyle::Button, selected.to_string());
let galley =
ui.fonts()
.layout_delayed_color(selected.to_string(), TextStyle::Button, f32::INFINITY);
let width = galley.size.x + ui.spacing().item_spacing.x + icon_size.x;
let width = width.at_least(full_minimum_width);
@@ -181,7 +181,7 @@ fn combo_box<R>(
let text_rect = Align2::LEFT_CENTER.align_size_within_rect(galley.size, rect);
ui.painter()
.galley(text_rect.min, galley, visuals.text_color());
.galley_with_color(text_rect.min, galley, visuals.text_color());
});
if button_response.clicked() {

View File

@@ -848,8 +848,9 @@ impl TitleBar {
let full_top_rect = Rect::from_x_y_ranges(self.rect.x_range(), self.min_rect.y_range());
let text_pos = emath::align::center_size_in_rect(self.title_galley.size, full_top_rect);
let text_pos = text_pos.left_top() - 1.5 * Vec2::Y; // HACK: center on x-height of text (looks better)
let text_color = ui.visuals().text_color();
self.title_label
.paint_galley(ui, text_pos, self.title_galley);
.paint_galley(ui, text_pos, self.title_galley, false, text_color);
if let Some(content_response) = &content_response {
// paint separator between title and content:

View File

@@ -377,6 +377,10 @@ pub use {
widgets::*,
};
pub mod text {
pub use epaint::text::{Galley, LayoutJob, LayoutSection, TextFormat, TAB_SIZE};
}
// ----------------------------------------------------------------------------
/// Helper function that adds a label when compiling with debug assertions enabled.

View File

@@ -219,9 +219,7 @@ impl Painter {
color: Color32,
text: impl ToString,
) -> Rect {
let galley = self
.fonts()
.layout_no_wrap(TextStyle::Monospace, text.to_string());
let galley = self.layout_no_wrap(text.to_string(), TextStyle::Monospace, color);
let rect = anchor.anchor_rect(Rect::from_min_size(pos, galley.size));
let frame_rect = rect.expand(2.0);
self.add(Shape::Rect {
@@ -231,7 +229,7 @@ impl Painter {
// stroke: Stroke::new(1.0, color),
stroke: Default::default(),
});
self.galley(rect.min, galley, color);
self.galley(rect.min, galley);
frame_rect
}
}
@@ -331,7 +329,7 @@ impl Painter {
/// To center the text at the given position, use `anchor: (Center, Center)`.
///
/// To find out the size of text before painting it, use
/// [`Self::layout_no_wrap`] or [`Self::layout_multiline`].
/// [`Self::layout`] or [`Self::layout_no_wrap`].
///
/// Returns where the text ended up.
#[allow(clippy::needless_pass_by_value)]
@@ -343,57 +341,69 @@ impl Painter {
text_style: TextStyle,
text_color: Color32,
) -> Rect {
let galley = self.layout_no_wrap(text_style, text.to_string());
let galley = self.layout_no_wrap(text.to_string(), text_style, text_color);
let rect = anchor.anchor_rect(Rect::from_min_size(pos, galley.size));
self.galley(rect.min, galley, text_color);
self.galley(rect.min, galley);
rect
}
/// Will line break at `\n`.
///
/// Paint the results with [`Self::galley`].
/// Always returns at least one row.
#[inline(always)]
pub fn layout_no_wrap(&self, text_style: TextStyle, text: String) -> std::sync::Arc<Galley> {
self.layout_multiline(text_style, text, f32::INFINITY)
}
/// Will wrap text at the given width and line break at `\n`.
///
/// Paint the results with [`Self::galley`].
/// Always returns at least one row.
#[inline(always)]
pub fn layout_multiline(
pub fn layout(
&self,
text_style: TextStyle,
text: String,
max_width_in_points: f32,
text_style: TextStyle,
color: crate::Color32,
wrap_width: f32,
) -> std::sync::Arc<Galley> {
self.fonts()
.layout_multiline(text_style, text, max_width_in_points)
self.fonts().layout(text, text_style, color, wrap_width)
}
/// Will line break at `\n`.
///
/// Paint the results with [`Self::galley`].
#[inline(always)]
pub fn layout_no_wrap(
&self,
text: String,
text_style: TextStyle,
color: crate::Color32,
) -> std::sync::Arc<Galley> {
self.fonts().layout(text, text_style, color, f32::INFINITY)
}
/// Paint text that has already been layed out in a [`Galley`].
///
/// You can create the `Galley` with [`Self::layout_no_wrap`] or [`Self::layout_multiline`].
/// You can create the `Galley` with [`Self::layout`].
///
/// If you want to change the color of the text, use [`Self::galley_with_color`].
#[inline(always)]
pub fn galley(&self, pos: Pos2, galley: std::sync::Arc<Galley>, color: Color32) {
self.galley_with_italics(pos, galley, color, false)
pub fn galley(&self, pos: Pos2, galley: std::sync::Arc<Galley>) {
if !galley.is_empty() {
self.add(Shape::galley(pos, galley));
}
}
pub fn galley_with_italics(
/// Paint text that has already been layed out in a [`Galley`].
///
/// You can create the `Galley` with [`Self::layout`].
///
/// The text color in the [`Galley`] will be replaced with the given color.
#[inline(always)]
pub fn galley_with_color(
&self,
pos: Pos2,
galley: std::sync::Arc<Galley>,
color: Color32,
fake_italics: bool,
text_color: Color32,
) {
if !galley.is_empty() {
self.add(Shape::Text {
pos,
galley,
color,
fake_italics,
underline: Stroke::none(),
override_text_color: Some(text_color),
});
}
}

View File

@@ -233,6 +233,7 @@ pub struct Visuals {
}
impl Visuals {
#[inline(always)]
pub fn noninteractive(&self) -> &WidgetVisuals {
&self.widgets.noninteractive
}
@@ -246,14 +247,17 @@ impl Visuals {
crate::color::tint_color_towards(self.text_color(), self.window_fill())
}
#[inline(always)]
pub fn strong_text_color(&self) -> Color32 {
self.widgets.active.text_color()
}
#[inline(always)]
pub fn window_fill(&self) -> Color32 {
self.widgets.noninteractive.bg_fill
}
#[inline(always)]
pub fn window_stroke(&self) -> Stroke {
self.widgets.noninteractive.bg_stroke
}
@@ -325,6 +329,7 @@ pub struct WidgetVisuals {
}
impl WidgetVisuals {
#[inline(always)]
pub fn text_color(&self) -> Color32 {
self.fg_stroke.color
}

View File

@@ -1,5 +1,14 @@
use crate::*;
/// For those of us who miss `a ? yes : no`.
fn select<T>(b: bool, if_true: T, if_false: T) -> T {
if b {
if_true
} else {
if_false
}
}
/// Clickable button with text.
///
/// See also [`Ui::button`].
@@ -150,12 +159,10 @@ impl Button {
let total_extra = button_padding + button_padding;
let wrap = wrap.unwrap_or_else(|| ui.wrap_text());
let galley = if wrap {
ui.fonts()
.layout_multiline(text_style, text, ui.available_width() - total_extra.x)
} else {
ui.fonts().layout_no_wrap(text_style, text)
};
let wrap_width = select(wrap, ui.available_width() - total_extra.x, f32::INFINITY);
let galley = ui
.fonts()
.layout_delayed_color(text, text_style, wrap_width);
let mut desired_size = galley.size + 2.0 * button_padding;
if !small {
@@ -164,7 +171,7 @@ impl Button {
desired_size = desired_size.at_least(min_size);
let (rect, response) = ui.allocate_at_least(desired_size, sense);
response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, &galley.text));
response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, galley.text()));
if ui.clip_rect().intersects(rect) {
let visuals = ui.style().interact(&response);
@@ -187,7 +194,7 @@ impl Button {
let text_color = text_color
.or(ui.visuals().override_text_color)
.unwrap_or_else(|| visuals.text_color());
ui.painter().galley(text_pos, galley, text_color);
ui.painter().galley_with_color(text_pos, galley, text_color);
}
response
@@ -274,12 +281,14 @@ impl<'a> Widget for Checkbox<'a> {
let button_padding = spacing.button_padding;
let total_extra = button_padding + vec2(icon_width + icon_spacing, 0.0) + button_padding;
let galley = if ui.wrap_text() {
ui.fonts()
.layout_multiline(text_style, text, ui.available_width() - total_extra.x)
} else {
ui.fonts().layout_no_wrap(text_style, text)
};
let wrap_width = select(
ui.wrap_text(),
ui.available_width() - total_extra.x,
f32::INFINITY,
);
let galley = ui
.fonts()
.layout_delayed_color(text, text_style, wrap_width);
let mut desired_size = total_extra + galley.size;
desired_size = desired_size.at_least(spacing.interact_size);
@@ -290,7 +299,8 @@ impl<'a> Widget for Checkbox<'a> {
*checked = !*checked;
response.mark_changed();
}
response.widget_info(|| WidgetInfo::selected(WidgetType::Checkbox, *checked, &galley.text));
response
.widget_info(|| WidgetInfo::selected(WidgetType::Checkbox, *checked, galley.text()));
// let visuals = ui.style().interact_selectable(&response, *checked); // too colorful
let visuals = ui.style().interact(&response);
@@ -321,7 +331,7 @@ impl<'a> Widget for Checkbox<'a> {
let text_color = text_color
.or(ui.visuals().override_text_color)
.unwrap_or_else(|| visuals.text_color());
ui.painter().galley(text_pos, galley, text_color);
ui.painter().galley_with_color(text_pos, galley, text_color);
response
}
}
@@ -395,19 +405,21 @@ impl Widget for RadioButton {
let button_padding = ui.spacing().button_padding;
let total_extra = button_padding + vec2(icon_width + icon_spacing, 0.0) + button_padding;
let galley = if ui.wrap_text() {
ui.fonts()
.layout_multiline(text_style, text, ui.available_width() - total_extra.x)
} else {
ui.fonts().layout_no_wrap(text_style, text)
};
let wrap_width = select(
ui.wrap_text(),
ui.available_width() - total_extra.x,
f32::INFINITY,
);
let galley = ui
.fonts()
.layout_delayed_color(text, text_style, wrap_width);
let mut desired_size = total_extra + galley.size;
desired_size = desired_size.at_least(ui.spacing().interact_size);
desired_size.y = desired_size.y.max(icon_width);
let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click());
response
.widget_info(|| WidgetInfo::selected(WidgetType::RadioButton, checked, &galley.text));
.widget_info(|| WidgetInfo::selected(WidgetType::RadioButton, checked, galley.text()));
let text_pos = pos2(
rect.min.x + button_padding.x + icon_width + icon_spacing,
@@ -441,7 +453,7 @@ impl Widget for RadioButton {
let text_color = text_color
.or(ui.visuals().override_text_color)
.unwrap_or_else(|| visuals.text_color());
painter.galley(text_pos, galley, text_color);
painter.galley_with_color(text_pos, galley, text_color);
response
}
}

View File

@@ -21,7 +21,7 @@ impl Hyperlink {
let url = url.to_string();
Self {
url: url.clone(),
label: Label::new(url),
label: Label::new(url).sense(Sense::click()),
}
}
@@ -54,9 +54,8 @@ impl Hyperlink {
impl Widget for Hyperlink {
fn ui(self, ui: &mut Ui) -> Response {
let Hyperlink { url, label } = self;
let galley = label.layout(ui);
let (rect, response) = ui.allocate_exact_size(galley.size, Sense::click());
response.widget_info(|| WidgetInfo::labeled(WidgetType::Hyperlink, &galley.text));
let (pos, galley, response) = label.layout_in_ui(ui);
response.widget_info(|| WidgetInfo::labeled(WidgetType::Hyperlink, galley.text()));
if response.hovered() {
ui.ctx().output().cursor_icon = CursorIcon::PointingHand;
@@ -78,19 +77,18 @@ impl Widget for Hyperlink {
let color = ui.visuals().hyperlink_color;
let visuals = ui.style().interact(&response);
if response.hovered() || response.has_focus() {
// Underline:
for row in &galley.rows {
let rect = row.rect().translate(rect.min.to_vec2());
ui.painter().line_segment(
[rect.left_bottom(), rect.right_bottom()],
(visuals.fg_stroke.width, color),
);
}
}
let underline = if response.hovered() || response.has_focus() {
Stroke::new(visuals.fg_stroke.width, color)
} else {
Stroke::none()
};
let label = label.text_color(color);
label.paint_galley(ui, rect.min, galley);
ui.painter().add(Shape::Text {
pos,
galley,
override_text_color: Some(color),
underline,
});
response.on_hover_text(url)
}

View File

@@ -1,5 +1,8 @@
use crate::*;
use epaint::Galley;
use epaint::{
text::{LayoutJob, LayoutSection, TextFormat},
Galley,
};
use std::sync::Arc;
/// Static text.
@@ -162,20 +165,111 @@ impl Label {
impl Label {
pub fn layout(&self, ui: &Ui) -> Arc<Galley> {
let max_width = ui.available_width();
self.layout_width(ui, max_width)
let line_color = self.get_text_color(ui, ui.visuals().text_color());
self.layout_width(ui, max_width, line_color)
}
pub fn layout_width(&self, ui: &Ui, max_width: f32) -> Arc<Galley> {
/// `line_color`: used for underline and strikethrough, if any.
pub fn layout_width(&self, ui: &Ui, max_width: f32, line_color: Color32) -> Arc<Galley> {
self.layout_impl(ui, 0.0, max_width, 0.0, line_color)
}
fn layout_impl(
&self,
ui: &Ui,
leading_space: f32,
max_width: f32,
first_row_min_height: f32,
line_color: Color32,
) -> Arc<Galley> {
let text_style = self.text_style_or_default(ui.style());
let wrap_width = if self.should_wrap(ui) {
max_width
} else {
f32::INFINITY
};
let galley = ui
.fonts()
.layout_multiline(text_style, self.text.clone(), wrap_width); // TODO: avoid clone
self.valign_galley(ui, text_style, galley)
let mut background_color = self.background_color;
if self.code {
background_color = ui.visuals().code_bg_color;
}
let underline = if self.underline {
Stroke::new(1.0, line_color)
} else {
Stroke::none()
};
let strikethrough = if self.strikethrough {
Stroke::new(1.0, line_color)
} else {
Stroke::none()
};
let valign = if self.raised {
Align::TOP
} else {
ui.layout().vertical_align()
};
let job = LayoutJob {
text: self.text.clone(), // TODO: avoid clone
sections: vec![LayoutSection {
leading_space,
byte_range: 0..self.text.len(),
format: TextFormat {
style: text_style,
color: Color32::TEMPORARY_COLOR,
background: background_color,
italics: self.italics,
underline,
strikethrough,
valign,
},
}],
wrap_width,
first_row_min_height,
..Default::default()
};
ui.fonts().layout_job(job)
}
/// `has_focus`: the item is selected with the keyboard, so highlight with underline.
/// `response_color`: Unless we have a special color set, use this.
pub(crate) fn paint_galley(
&self,
ui: &mut Ui,
pos: Pos2,
galley: Arc<Galley>,
has_focus: bool,
response_color: Color32,
) {
let text_color = self.get_text_color(ui, response_color);
let underline = if has_focus {
Stroke::new(1.0, text_color)
} else {
Stroke::none()
};
ui.painter().add(Shape::Text {
pos,
galley,
override_text_color: Some(text_color),
underline,
});
}
/// `response_color`: Unless we have a special color set, use this.
fn get_text_color(&self, ui: &Ui, response_color: Color32) -> Color32 {
if let Some(text_color) = self.text_color {
text_color
} else if self.strong {
ui.visuals().strong_text_color()
} else if self.weak {
ui.visuals().weak_text_color()
} else {
response_color
}
}
pub fn font_height(&self, fonts: &epaint::text::Fonts, style: &Style) -> f32 {
@@ -191,79 +285,6 @@ impl Label {
// TODO: a paint method for painting anywhere in a ui.
// This should be the easiest method of putting text anywhere.
pub fn paint_galley(&self, ui: &mut Ui, pos: Pos2, galley: Arc<Galley>) {
self.paint_galley_impl(ui, pos, galley, false, ui.visuals().text_color())
}
fn paint_galley_impl(
&self,
ui: &mut Ui,
pos: Pos2,
galley: Arc<Galley>,
has_focus: bool,
response_color: Color32,
) {
let Self {
mut background_color,
code,
strong,
weak,
strikethrough,
underline,
italics,
raised: _,
..
} = *self;
let underline = underline || has_focus;
let text_color = if let Some(text_color) = self.text_color {
text_color
} else if strong {
ui.visuals().strong_text_color()
} else if weak {
ui.visuals().weak_text_color()
} else {
response_color
};
if code {
background_color = ui.visuals().code_bg_color;
}
let mut lines = vec![];
if strikethrough || underline || background_color != Color32::TRANSPARENT {
for row in &galley.rows {
let rect = row.rect().translate(pos.to_vec2());
if background_color != Color32::TRANSPARENT {
let rect = rect.expand(1.0); // looks better
ui.painter().rect_filled(rect, 0.0, background_color);
}
let stroke_width = 1.0;
if strikethrough {
lines.push(Shape::line_segment(
[rect.left_center(), rect.right_center()],
(stroke_width, text_color),
));
}
if underline {
lines.push(Shape::line_segment(
[rect.left_bottom(), rect.right_bottom()],
(stroke_width, text_color),
));
}
}
}
ui.painter()
.galley_with_italics(pos, galley, text_color, italics);
ui.painter().extend(lines);
}
/// Read the text style, or get the default for the current style
pub fn text_style_or_default(&self, style: &Style) -> TextStyle {
self.text_style
@@ -282,38 +303,9 @@ impl Label {
})
}
fn valign_galley(
&self,
ui: &Ui,
text_style: TextStyle,
mut galley: Arc<Galley>,
) -> Arc<Galley> {
if text_style == TextStyle::Small {
// Hacky McHackface strikes again:
let dy = if self.raised {
-2.0
} else {
let normal_text_height = ui.fonts()[TextStyle::Body].row_height();
let font_height = ui.fonts().row_height(text_style);
(normal_text_height - font_height) / 2.0 - 1.0 // center
// normal_text_height - font_height // align bottom
};
if dy != 0.0 {
for row in &mut Arc::make_mut(&mut galley).rows {
row.translate_y(dy);
}
}
}
galley
}
}
impl Widget for Label {
fn ui(self, ui: &mut Ui) -> Response {
/// Do layout and place the galley in the ui, without painting it or adding widget info.
pub(crate) fn layout_in_ui(&self, ui: &mut Ui) -> (Pos2, Arc<Galley>, Response) {
let sense = self.sense;
let max_width = ui.available_width();
if self.should_wrap(ui)
@@ -328,59 +320,44 @@ impl Widget for Label {
let first_row_indentation = max_width - ui.available_size_before_wrap().x;
egui_assert!(first_row_indentation.is_finite());
let text_style = self.text_style_or_default(ui.style());
let galley = ui.fonts().layout_multiline_with_indentation_and_max_width(
text_style,
self.text.clone(),
let first_row_min_height = cursor.height();
let default_color = self.get_text_color(ui, ui.visuals().text_color());
let galley = self.layout_impl(
ui,
first_row_indentation,
max_width,
first_row_min_height,
default_color,
);
let mut galley: Galley = (*galley).clone();
let pos = pos2(ui.max_rect().left(), ui.cursor().top());
assert!(!galley.rows.is_empty(), "Galleys are never empty");
// Center first row within the cursor:
let dy = 0.5 * (cursor.height() - galley.rows[0].height());
galley.rows[0].translate_y(dy);
// We could be sharing the first row with e.g. a button which is higher than text.
// So we need to compensate for that:
if let Some(row) = galley.rows.get_mut(1) {
if pos.y + row.y_min < cursor.bottom() {
let y_translation = cursor.bottom() - row.y_min - pos.y;
if y_translation != 0.0 {
for row in galley.rows.iter_mut().skip(1) {
row.translate_y(y_translation);
}
}
}
}
let galley = self.valign_galley(ui, text_style, Arc::new(galley));
let rect = galley.rows[0].rect().translate(vec2(pos.x, pos.y));
// collect a response from many rows:
let rect = galley.rows[0].rect.translate(vec2(pos.x, pos.y));
let mut response = ui.allocate_rect(rect, sense);
for row in galley.rows.iter().skip(1) {
let rect = row.rect().translate(vec2(pos.x, pos.y));
let rect = row.rect.translate(vec2(pos.x, pos.y));
response |= ui.allocate_rect(rect, sense);
}
response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, &galley.text));
let response_color = ui.style().interact(&response).text_color();
self.paint_galley_impl(ui, pos, galley, response.has_focus(), response_color);
response
(pos, galley, response)
} else {
let galley = self.layout(ui);
let (rect, response) = ui.allocate_exact_size(galley.size, sense);
response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, &galley.text));
let response_color = ui.style().interact(&response).text_color();
self.paint_galley_impl(ui, rect.min, galley, response.has_focus(), response_color);
response
(rect.min, galley, response)
}
}
}
impl Widget for Label {
fn ui(self, ui: &mut Ui) -> Response {
let (pos, galley, response) = self.layout_in_ui(ui);
response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, galley.text()));
let response_color = ui.style().interact(&response).text_color();
self.paint_galley(ui, pos, galley, response.has_focus(), response_color);
response
}
}
impl From<&str> for Label {
fn from(s: &str) -> Label {
Label::new(s)

View File

@@ -912,16 +912,11 @@ impl PlotItem for Text {
let pos = transform.position_from_value(&self.position);
let galley = ui
.fonts()
.layout_multiline(self.style, self.text.clone(), f32::INFINITY);
.layout_no_wrap(self.text.clone(), self.style, color);
let rect = self
.anchor
.anchor_rect(Rect::from_min_size(pos, galley.size));
shapes.push(Shape::Text {
pos: rect.min,
galley,
color,
fake_italics: false,
});
shapes.push(Shape::galley(rect.min, galley));
if self.highlight {
shapes.push(Shape::rect_stroke(
rect.expand(2.0),

View File

@@ -90,7 +90,9 @@ impl LegendEntry {
hovered,
} = self;
let galley = ui.fonts().layout_no_wrap(ui.style().body_text_style, text);
let galley =
ui.fonts()
.layout_delayed_color(text, ui.style().body_text_style, f32::INFINITY);
let icon_size = galley.size.y;
let icon_spacing = icon_size / 5.0;
@@ -99,7 +101,8 @@ impl LegendEntry {
let desired_size = total_extra + galley.size;
let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click());
response.widget_info(|| WidgetInfo::selected(WidgetType::Checkbox, *checked, &galley.text));
response
.widget_info(|| WidgetInfo::selected(WidgetType::Checkbox, *checked, galley.text()));
let visuals = ui.style().interact(&response);
let label_on_the_left = ui.layout().horizontal_align() == Align::RIGHT;
@@ -142,7 +145,7 @@ impl LegendEntry {
};
let text_position = pos2(text_position_x, rect.center().y - 0.5 * galley.size.y);
painter.galley(text_position, galley, visuals.text_color());
painter.galley_with_color(text_position, galley, visuals.text_color());
*checked ^= response.clicked_by(PointerButton::Primary);
*hovered = response.hovered();

View File

@@ -626,7 +626,7 @@ impl Prepared {
let color = color_from_alpha(ui, text_alpha);
let text = emath::round_to_decimals(value_main, 5).to_string(); // hack
let galley = ui.fonts().layout_single_line(text_style, text);
let galley = ui.painter().layout_no_wrap(text, text_style, color);
let mut text_pos = pos_in_gui + vec2(1.0, -galley.size.y);
@@ -635,12 +635,7 @@ impl Prepared {
.at_most(transform.frame().max[1 - axis] - galley.size[1 - axis] - 2.0)
.at_least(transform.frame().min[1 - axis] + 1.0);
shapes.push(Shape::Text {
pos: text_pos,
galley,
color,
fake_italics: false,
});
shapes.push(Shape::galley(text_pos, galley));
}
}

View File

@@ -59,18 +59,21 @@ impl Widget for SelectableLabel {
let button_padding = ui.spacing().button_padding;
let total_extra = button_padding + button_padding;
let galley = if ui.wrap_text() {
ui.fonts()
.layout_multiline(text_style, text, ui.available_width() - total_extra.x)
let wrap_width = if ui.wrap_text() {
ui.available_width() - total_extra.x
} else {
ui.fonts().layout_no_wrap(text_style, text)
f32::INFINITY
};
let galley = ui
.fonts()
.layout_delayed_color(text, text_style, wrap_width);
let mut desired_size = total_extra + galley.size;
desired_size.y = desired_size.y.at_least(ui.spacing().interact_size.y);
let (rect, response) = ui.allocate_at_least(desired_size, Sense::click());
response.widget_info(|| {
WidgetInfo::selected(WidgetType::SelectableLabel, selected, &galley.text)
WidgetInfo::selected(WidgetType::SelectableLabel, selected, galley.text())
});
let text_pos = ui
@@ -93,7 +96,7 @@ impl Widget for SelectableLabel {
.visuals
.override_text_color
.unwrap_or_else(|| visuals.text_color());
ui.painter().galley(text_pos, galley, text_color);
ui.painter().galley_with_color(text_pos, galley, text_color);
response
}
}

View File

@@ -1,6 +1,7 @@
use crate::{output::OutputEvent, util::undoer::Undoer, *};
use epaint::{text::cursor::*, *};
use epaint::text::{cursor::*, Galley, LayoutJob};
use std::ops::Range;
use std::sync::Arc;
#[derive(Clone, Debug, Default)]
#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))]
@@ -222,7 +223,6 @@ impl TextBuffer for String {
/// ```
///
#[must_use = "You should put this widget in an ui with `ui.add(widget);`"]
#[derive(Debug)]
pub struct TextEdit<'t, S: TextBuffer = String> {
text: &'t mut S,
hint_text: String,
@@ -230,6 +230,7 @@ pub struct TextEdit<'t, S: TextBuffer = String> {
id_source: Option<Id>,
text_style: Option<TextStyle>,
text_color: Option<Color32>,
layouter: Option<&'t mut dyn FnMut(&Ui, &str, f32) -> Arc<Galley>>,
password: bool,
frame: bool,
multiline: bool,
@@ -239,6 +240,7 @@ pub struct TextEdit<'t, S: TextBuffer = String> {
lock_focus: bool,
cursor_at_end: bool,
}
impl<'t, S: TextBuffer> TextEdit<'t, S> {
pub fn cursor(ui: &Ui, id: Id) -> Option<CursorPair> {
ui.memory()
@@ -251,33 +253,23 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> {
impl<'t, S: TextBuffer> TextEdit<'t, S> {
/// No newlines (`\n`) allowed. Pressing enter key will result in the `TextEdit` losing focus (`response.lost_focus`).
pub fn singleline(text: &'t mut S) -> Self {
TextEdit {
text,
hint_text: Default::default(),
id: None,
id_source: None,
text_style: None,
text_color: None,
password: false,
frame: true,
multiline: false,
enabled: true,
desired_width: None,
Self {
desired_height_rows: 1,
lock_focus: false,
cursor_at_end: true,
multiline: false,
..Self::multiline(text)
}
}
/// A `TextEdit` for multiple lines. Pressing enter key will create a new line.
pub fn multiline(text: &'t mut S) -> Self {
TextEdit {
Self {
text,
hint_text: Default::default(),
id: None,
id_source: None,
text_style: None,
text_color: None,
layouter: None,
password: false,
frame: true,
multiline: true,
@@ -337,6 +329,34 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> {
self
}
/// Override how text is being shown inside the `TextEdit`.
///
/// This can be used to implement things like syntax highlighting.
///
/// This function will be called at least once per frame,
/// so it is strongly suggested that you cache the results of any syntax highlighter
/// so as not to waste CPU highlighting the same string every frame.
///
/// The arguments is the enclosing [`Ui`] (so you can access e.g. [`Ui::fonts`]),
/// the text and the wrap width.
///
/// ```
/// # let ui = &mut egui::Ui::__test();
/// # let mut my_code = String::new();
/// # fn my_memoized_highlighter(s: &str) -> egui::text::LayoutJob { Default::default() }
/// let mut layouter = |ui: &egui::Ui, string: &str, wrap_width: f32| {
/// let mut layout_job: egui::text::LayoutJob = my_memoized_highlighter(string);
/// layout_job.wrap_width = wrap_width;
/// ui.fonts().layout_job(layout_job)
/// };
/// ui.add(egui::TextEdit::multiline(&mut my_code).layouter(&mut layouter));
/// ```
pub fn layouter(mut self, layouter: &'t mut dyn FnMut(&Ui, &str, f32) -> Arc<Galley>) -> Self {
self.layouter = Some(layouter);
self
}
/// Default is `true`. If set to `false` then you cannot edit the text.
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
@@ -349,7 +369,8 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> {
self
}
/// Set to 0.0 to keep as small as possible
/// Set to 0.0 to keep as small as possible.
/// Set to [`f32::INFINITY`] to take up all available space.
pub fn desired_width(mut self, desired_width: f32) -> Self {
self.desired_width = Some(desired_width);
self
@@ -433,6 +454,14 @@ fn mask_massword(text: &str) -> String {
.collect::<String>()
}
fn mask_if_password(is_password: bool, text: &str) -> String {
if is_password {
mask_massword(text)
} else {
text.to_owned()
}
}
impl<'t, S: TextBuffer> TextEdit<'t, S> {
fn content_ui(self, ui: &mut Ui) -> Response {
let TextEdit {
@@ -442,6 +471,7 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> {
id_source,
text_style,
text_color,
layouter,
password,
frame: _,
multiline,
@@ -452,19 +482,16 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> {
cursor_at_end,
} = self;
let mask_if_password = |text: &str| {
if password {
mask_massword(text)
} else {
text.to_owned()
}
};
let text_color = text_color
.or(ui.visuals().override_text_color)
// .unwrap_or_else(|| ui.style().interact(&response).text_color()); // too bright
.unwrap_or_else(|| ui.visuals().widgets.inactive.text_color());
let prev_text = text.as_ref().to_owned();
let text_style = text_style
.or(ui.style().override_text_style)
.unwrap_or_else(|| ui.style().body_text_style);
let line_spacing = ui.fonts().row_height(text_style);
let row_height = ui.fonts().row_height(text_style);
const MIN_WIDTH: f32 = 24.0; // Never make a `TextEdit` more narrow than this.
let available_width = ui.available_width().at_least(MIN_WIDTH);
let desired_width = desired_width.unwrap_or_else(|| ui.spacing().text_edit_width);
@@ -474,24 +501,26 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> {
desired_width.min(available_width)
};
let make_galley = |ui: &Ui, wrap_width: f32, text: &str| {
let text = mask_if_password(text);
if multiline {
ui.fonts().layout_multiline(text_style, text, wrap_width)
let mut default_layouter = move |ui: &Ui, text: &str, wrap_width: f32| {
let text = mask_if_password(password, text);
ui.fonts().layout_job(if multiline {
LayoutJob::simple(text, text_style, text_color, wrap_width)
} else {
ui.fonts().layout_single_line(text_style, text)
}
LayoutJob::simple_singleline(text, text_style, text_color)
})
};
let layouter = layouter.unwrap_or(&mut default_layouter);
let copy_if_not_password = |ui: &Ui, text: String| {
if !password {
ui.ctx().output().copied_text = text;
}
};
let mut galley = make_galley(ui, wrap_width, text.as_ref());
let mut galley = layouter(ui, text.as_ref(), wrap_width);
let desired_height = (desired_height_rows.at_least(1) as f32) * line_spacing;
let desired_height = (desired_height_rows.at_least(1) as f32) * row_height;
let desired_size = vec2(wrap_width, galley.size.y.max(desired_height));
let (auto_id, rect) = ui.allocate_space(desired_size);
@@ -525,7 +554,14 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> {
&& ui.input().pointer.is_moving()
{
// preview:
paint_cursor_end(ui, &painter, response.rect.min, &galley, &cursor_at_pointer);
paint_cursor_end(
ui,
row_height,
&painter,
response.rect.min,
&galley,
&cursor_at_pointer,
);
}
if response.double_clicked() {
@@ -729,7 +765,7 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> {
response.mark_changed();
// Layout again to avoid frame delay, and to keep `text` and `galley` in sync.
galley = make_galley(ui, wrap_width, text.as_ref());
galley = layouter(ui, text.as_ref(), wrap_width);
// Set cursorp using new galley:
cursorp = CursorPair {
@@ -778,7 +814,14 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> {
if ui.memory().has_focus(id) {
if let Some(cursorp) = state.cursorp {
paint_cursor_selection(ui, &painter, text_draw_pos, &galley, &cursorp);
paint_cursor_end(ui, &painter, text_draw_pos, &galley, &cursorp.primary);
paint_cursor_end(
ui,
row_height,
&painter,
text_draw_pos,
&galley,
&cursorp.primary,
);
if enabled {
ui.ctx().output().text_cursor_pos = Some(
@@ -791,21 +834,15 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> {
}
}
let text_color = text_color
.or(ui.visuals().override_text_color)
// .unwrap_or_else(|| ui.style().interact(&response).text_color()); // too bright
.unwrap_or_else(|| ui.visuals().widgets.inactive.text_color());
painter.galley(text_draw_pos, galley, text_color);
painter.galley(text_draw_pos, galley);
if text.as_ref().is_empty() && !hint_text.is_empty() {
let galley = if multiline {
ui.fonts()
.layout_multiline(text_style, hint_text, desired_size.x)
} else {
ui.fonts().layout_single_line(text_style, hint_text)
};
let hint_text_color = ui.visuals().weak_text_color();
painter.galley(response.rect.min, galley, hint_text_color);
let galley = ui.fonts().layout_job(if multiline {
LayoutJob::simple(hint_text, text_style, hint_text_color, desired_size.x)
} else {
LayoutJob::simple_singleline(hint_text, text_style, hint_text_color)
});
painter.galley(response.rect.min, galley);
}
ui.memory().id_data.insert(id, state);
@@ -822,16 +859,18 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> {
if response.changed {
response.widget_info(|| {
WidgetInfo::text_edit(
mask_if_password(prev_text.as_str()),
mask_if_password(text.as_str()),
mask_if_password(password, prev_text.as_str()),
mask_if_password(password, text.as_str()),
)
});
} else if selection_changed {
let text_cursor = text_cursor.unwrap();
let char_range =
text_cursor.primary.ccursor.index..=text_cursor.secondary.ccursor.index;
let info =
WidgetInfo::text_selection_changed(char_range, mask_if_password(text.as_str()));
let info = WidgetInfo::text_selection_changed(
char_range,
mask_if_password(password, text.as_str()),
);
response
.ctx
.output()
@@ -840,8 +879,8 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> {
} else {
response.widget_info(|| {
WidgetInfo::text_edit(
mask_if_password(prev_text.as_str()),
mask_if_password(text.as_str()),
mask_if_password(password, prev_text.as_str()),
mask_if_password(password, text.as_str()),
)
});
}
@@ -871,7 +910,7 @@ fn paint_cursor_selection(
let left = if ri == min.row {
row.x_offset(min.column)
} else {
row.min_x()
row.rect.left()
};
let right = if ri == max.row {
row.x_offset(max.column)
@@ -881,18 +920,29 @@ fn paint_cursor_selection(
} else {
0.0
};
row.max_x() + newline_size
row.rect.right() + newline_size
};
let rect = Rect::from_min_max(pos + vec2(left, row.y_min), pos + vec2(right, row.y_max));
let rect = Rect::from_min_max(
pos + vec2(left, row.min_y()),
pos + vec2(right, row.max_y()),
);
painter.rect_filled(rect, 0.0, color);
}
}
fn paint_cursor_end(ui: &mut Ui, painter: &Painter, pos: Pos2, galley: &Galley, cursor: &Cursor) {
fn paint_cursor_end(
ui: &mut Ui,
row_height: f32,
painter: &Painter,
pos: Pos2,
galley: &Galley,
cursor: &Cursor,
) {
let stroke = ui.visuals().selection.stroke;
let cursor_pos = galley.pos_from_cursor(cursor).translate(pos.to_vec2());
let cursor_pos = cursor_pos.expand(1.5); // slightly above/below row
let mut cursor_pos = galley.pos_from_cursor(cursor).translate(pos.to_vec2());
cursor_pos.max.y = cursor_pos.max.y.at_least(cursor_pos.min.y + row_height); // Handle completely empty galleys
cursor_pos = cursor_pos.expand(1.5); // slightly above/below row
let top = cursor_pos.center_top();
let bottom = cursor_pos.center_bottom();
@@ -1102,7 +1152,7 @@ fn move_single_cursor(cursor: &mut Cursor, galley: &Galley, key: Key, modifiers:
Key::ArrowLeft => {
if modifiers.alt || modifiers.ctrl {
// alt on mac, ctrl on windows
*cursor = galley.from_ccursor(ccursor_previous_word(&galley.text, cursor.ccursor));
*cursor = galley.from_ccursor(ccursor_previous_word(galley.text(), cursor.ccursor));
} else if modifiers.mac_cmd {
*cursor = galley.cursor_begin_of_row(cursor);
} else {
@@ -1112,7 +1162,7 @@ fn move_single_cursor(cursor: &mut Cursor, galley: &Galley, key: Key, modifiers:
Key::ArrowRight => {
if modifiers.alt || modifiers.ctrl {
// alt on mac, ctrl on windows
*cursor = galley.from_ccursor(ccursor_next_word(&galley.text, cursor.ccursor));
*cursor = galley.from_ccursor(ccursor_next_word(galley.text(), cursor.ccursor));
} else if modifiers.mac_cmd {
*cursor = galley.cursor_end_of_row(cursor);
} else {