Center-align all text vertically (#5117)

* Closes https://github.com/emilk/egui/issues/4929
* Builds on top of https://github.com/emilk/egui/pull/2724 by @lictex
(ptal!)
* Implement `Center` and `Max` vertical text alignment properly
* Change default vertical alignment of text to centering

The end result is that text centers better in buttons and other places,
especially when mixing in emojis.
Before, mixing text of different heights (e.g. emojis and latin text) in
a label or button would cause the text to jump vertically.

## Before
This is `master`, with custom `FontTweak` to move fonts up and down:
<img width="1714" alt="image"
src="https://github.com/user-attachments/assets/a10e2927-e824-4580-baea-124c0b38a527">
<img width="102" alt="image"
src="https://github.com/user-attachments/assets/cd41f415-197b-42cd-9558-d46d63c21dcb">


## After
This PR, with the default (zero) `FontTweak`

<img width="102" alt="image"
src="https://github.com/user-attachments/assets/15e7d896-66b1-4996-ab58-dd1850b19a63">

<img width="1714" alt="image"
src="https://github.com/user-attachments/assets/54ec708c-7698-4754-b1fc-fea0fd240ec9">
This commit is contained in:
Emil Ernerfeldt 2024-09-19 11:44:29 +02:00 committed by GitHub
parent bb9e874c83
commit 2a40d16e5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 148 additions and 60 deletions

View File

@ -4,6 +4,7 @@
use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc}; use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc};
use emath::Align;
use epaint::{text::FontTweak, Rounding, Shadow, Stroke}; use epaint::{text::FontTweak, Rounding, Shadow, Stroke};
use crate::{ use crate::{
@ -196,6 +197,11 @@ pub struct Style {
/// which will take precedence over this. /// which will take precedence over this.
pub override_font_id: Option<FontId>, pub override_font_id: Option<FontId>,
/// How to vertically align text.
///
/// Set to `None` to use align that depends on the current layout.
pub override_text_valign: Option<Align>,
/// The [`FontFamily`] and size you want to use for a specific [`TextStyle`]. /// The [`FontFamily`] and size you want to use for a specific [`TextStyle`].
/// ///
/// The most convenient way to look something up in this is to use [`TextStyle::resolve`]. /// The most convenient way to look something up in this is to use [`TextStyle::resolve`].
@ -1201,6 +1207,7 @@ impl Default for Style {
Self { Self {
override_font_id: None, override_font_id: None,
override_text_style: None, override_text_style: None,
override_text_valign: Some(Align::Center),
text_styles: default_text_styles(), text_styles: default_text_styles(),
drag_value_text_style: TextStyle::Button, drag_value_text_style: TextStyle::Button,
number_formatter: NumberFormatter(Arc::new(emath::format_with_decimals_in_range)), number_formatter: NumberFormatter(Arc::new(emath::format_with_decimals_in_range)),
@ -1501,6 +1508,7 @@ impl Style {
let Self { let Self {
override_font_id, override_font_id,
override_text_style, override_text_style,
override_text_valign,
text_styles, text_styles,
drag_value_text_style, drag_value_text_style,
number_formatter: _, // can't change callbacks in the UI number_formatter: _, // can't change callbacks in the UI
@ -1534,7 +1542,7 @@ impl Style {
ui.end_row(); ui.end_row();
ui.label("Override text style"); ui.label("Override text style");
crate::ComboBox::from_id_salt("Override text style") crate::ComboBox::from_id_salt("override_text_style")
.selected_text(match override_text_style { .selected_text(match override_text_style {
None => "None".to_owned(), None => "None".to_owned(),
Some(override_text_style) => override_text_style.to_string(), Some(override_text_style) => override_text_style.to_string(),
@ -1550,6 +1558,28 @@ impl Style {
}); });
ui.end_row(); ui.end_row();
fn valign_name(valign: Align) -> &'static str {
match valign {
Align::TOP => "Top",
Align::Center => "Center",
Align::BOTTOM => "Bottom",
}
}
ui.label("Override text valign");
crate::ComboBox::from_id_salt("override_text_valign")
.selected_text(match override_text_valign {
None => "None",
Some(override_text_valign) => valign_name(*override_text_valign),
})
.show_ui(ui, |ui| {
ui.selectable_value(override_text_valign, None, "None");
for align in [Align::TOP, Align::Center, Align::BOTTOM] {
ui.selectable_value(override_text_valign, Some(align), valign_name(align));
}
});
ui.end_row();
ui.label("Text style of DragValue"); ui.label("Text style of DragValue");
crate::ComboBox::from_id_salt("drag_value_text_style") crate::ComboBox::from_id_salt("drag_value_text_style")
.selected_text(drag_value_text_style.to_string()) .selected_text(drag_value_text_style.to_string())

View File

@ -77,7 +77,7 @@ pub fn update_accesskit_for_text_widget(
value.push(glyph.chr); value.push(glyph.chr);
character_lengths.push((value.len() - old_len) as _); character_lengths.push((value.len() - old_len) as _);
character_positions.push(glyph.pos.x - row.rect.min.x); character_positions.push(glyph.pos.x - row.rect.min.x);
character_widths.push(glyph.size.x); character_widths.push(glyph.advance_width);
} }
if row.ends_with_newline { if row.ends_with_newline {

View File

@ -617,6 +617,14 @@ impl Ui {
self.wrap_mode() == TextWrapMode::Wrap self.wrap_mode() == TextWrapMode::Wrap
} }
/// How to vertically align text
#[inline]
pub fn text_valign(&self) -> Align {
self.style()
.override_text_valign
.unwrap_or_else(|| self.layout().vertical_align())
}
/// Create a painter for a sub-region of this Ui. /// Create a painter for a sub-region of this Ui.
/// ///
/// The clip-rect of the returned [`Painter`] will be the intersection /// The clip-rect of the returned [`Painter`] will be the intersection

View File

@ -648,7 +648,7 @@ impl WidgetText {
available_width: f32, available_width: f32,
fallback_font: impl Into<FontSelection>, fallback_font: impl Into<FontSelection>,
) -> Arc<Galley> { ) -> Arc<Galley> {
let valign = ui.layout().vertical_align(); let valign = ui.text_valign();
let style = ui.style(); let style = ui.style();
let wrap_mode = wrap_mode.unwrap_or_else(|| ui.wrap_mode()); let wrap_mode = wrap_mode.unwrap_or_else(|| ui.wrap_mode());

View File

@ -251,9 +251,9 @@ impl Widget for Button<'_> {
if image.is_some() && galley.is_some() { if image.is_some() && galley.is_some() {
desired_size.x += ui.spacing().icon_spacing; desired_size.x += ui.spacing().icon_spacing;
} }
if let Some(text) = &galley { if let Some(galley) = &galley {
desired_size.x += text.size().x; desired_size.x += galley.size().x;
desired_size.y = desired_size.y.max(text.size().y); desired_size.y = desired_size.y.max(galley.size().y);
} }
if let Some(shortcut_galley) = &shortcut_galley { if let Some(shortcut_galley) = &shortcut_galley {
desired_size.x += gap_before_shortcut_text + shortcut_galley.size().x; desired_size.x += gap_before_shortcut_text + shortcut_galley.size().x;

View File

@ -162,7 +162,7 @@ impl Label {
return (pos, galley, response); return (pos, galley, response);
} }
let valign = ui.layout().vertical_align(); let valign = ui.text_valign();
let mut layout_job = self let mut layout_job = self
.text .text
.into_layout_job(ui.style(), FontSelection::Default, valign); .into_layout_job(ui.style(), FontSelection::Default, valign);

View File

@ -580,8 +580,17 @@ fn text_layout_demo(ui: &mut Ui) {
}; };
job.append( job.append(
"This is a demonstration of ", "This",
first_row_indentation, first_row_indentation,
TextFormat {
color: default_color,
font_id: FontId::proportional(20.0),
..Default::default()
},
);
job.append(
" is a demonstration of ",
0.0,
TextFormat { TextFormat {
color: default_color, color: default_color,
..Default::default() ..Default::default()
@ -632,7 +641,7 @@ fn text_layout_demo(ui: &mut Ui) {
"mixing ", "mixing ",
0.0, 0.0,
TextFormat { TextFormat {
font_id: FontId::proportional(17.0), font_id: FontId::proportional(20.0),
color: default_color, color: default_color,
..Default::default() ..Default::default()
}, },

View File

@ -468,6 +468,14 @@ impl Font {
(Some(font_impl), glyph_info) (Some(font_impl), glyph_info)
} }
pub(crate) fn ascent(&self) -> f32 {
if let Some(first) = self.fonts.first() {
first.ascent()
} else {
self.row_height
}
}
fn glyph_info_no_cache_or_fallback(&mut self, c: char) -> Option<(FontIndex, GlyphInfo)> { fn glyph_info_no_cache_or_fallback(&mut self, c: char) -> Option<(FontIndex, GlyphInfo)> {
for (font_index, font_impl) in self.fonts.iter().enumerate() { for (font_index, font_impl) in self.fonts.iter().enumerate() {
if let Some(glyph_info) = font_impl.glyph_info(c) { if let Some(glyph_info) = font_impl.glyph_info(c) {

View File

@ -189,7 +189,7 @@ impl Default for FontTweak {
scale: 1.0, scale: 1.0,
y_offset_factor: 0.0, y_offset_factor: 0.0,
y_offset: 0.0, y_offset: 0.0,
baseline_offset_factor: -0.0333, // makes the default fonts look more centered in buttons and such baseline_offset_factor: 0.0,
} }
} }
} }
@ -271,29 +271,26 @@ impl Default for FontDefinitions {
let mut families = BTreeMap::new(); let mut families = BTreeMap::new();
font_data.insert("Hack".to_owned(), FontData::from_static(HACK_REGULAR)); font_data.insert("Hack".to_owned(), FontData::from_static(HACK_REGULAR));
font_data.insert(
"Ubuntu-Light".to_owned(),
FontData::from_static(UBUNTU_LIGHT),
);
// Some good looking emojis. Use as first priority: // Some good looking emojis. Use as first priority:
font_data.insert( font_data.insert(
"NotoEmoji-Regular".to_owned(), "NotoEmoji-Regular".to_owned(),
FontData::from_static(NOTO_EMOJI_REGULAR).tweak(FontTweak { FontData::from_static(NOTO_EMOJI_REGULAR).tweak(FontTweak {
scale: 0.81, // make it smaller scale: 0.81, // Make smaller
..Default::default() ..Default::default()
}), }),
); );
font_data.insert(
"Ubuntu-Light".to_owned(),
FontData::from_static(UBUNTU_LIGHT),
);
// Bigger emojis, and more. <http://jslegers.github.io/emoji-icon-font/>: // Bigger emojis, and more. <http://jslegers.github.io/emoji-icon-font/>:
font_data.insert( font_data.insert(
"emoji-icon-font".to_owned(), "emoji-icon-font".to_owned(),
FontData::from_static(EMOJI_ICON).tweak(FontTweak { FontData::from_static(EMOJI_ICON).tweak(FontTweak {
scale: 0.88, // make it smaller scale: 0.90, // Make smaller
// probably not correct, but this does make texts look better (#2724 for details)
y_offset_factor: 0.11, // move glyphs down to better align with common fonts
baseline_offset_factor: -0.11, // ...now the entire row is a bit down so shift it back
..Default::default() ..Default::default()
}), }),
); );

View File

@ -171,8 +171,12 @@ fn layout_section(
paragraph.glyphs.push(Glyph { paragraph.glyphs.push(Glyph {
chr, chr,
pos: pos2(paragraph.cursor_x, f32::NAN), pos: pos2(paragraph.cursor_x, f32::NAN),
size: vec2(glyph_info.advance_width, line_height), advance_width: glyph_info.advance_width,
ascent: font_impl.map_or(0.0, |font| font.ascent()), // Failure to find the font here would be weird line_height,
font_impl_height: font_impl.map_or(0.0, |f| f.row_height()),
font_impl_ascent: font_impl.map_or(0.0, |f| f.ascent()),
font_height: font.row_height(),
font_ascent: font.ascent(),
uv_rect: glyph_info.uv_rect, uv_rect: glyph_info.uv_rect,
section_index, section_index,
}); });
@ -376,7 +380,7 @@ fn replace_last_glyph_with_overflow_character(
let (_, last_glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr); let (_, last_glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr);
let mut x = last_glyph.pos.x + last_glyph.size.x; let mut x = last_glyph.pos.x + last_glyph.advance_width;
let (font_impl, replacement_glyph_info) = font.font_impl_and_glyph_info(overflow_character); let (font_impl, replacement_glyph_info) = font.font_impl_and_glyph_info(overflow_character);
@ -391,8 +395,12 @@ fn replace_last_glyph_with_overflow_character(
row.glyphs.push(Glyph { row.glyphs.push(Glyph {
chr: overflow_character, chr: overflow_character,
pos: pos2(x, f32::NAN), pos: pos2(x, f32::NAN),
size: vec2(replacement_glyph_info.advance_width, line_height), advance_width: replacement_glyph_info.advance_width,
ascent: font_impl.map_or(0.0, |font| font.ascent()), // Failure to find the font here would be weird line_height,
font_impl_height: font_impl.map_or(0.0, |f| f.row_height()),
font_impl_ascent: font_impl.map_or(0.0, |f| f.ascent()),
font_height: font.row_height(),
font_ascent: font.ascent(),
uv_rect: replacement_glyph_info.uv_rect, uv_rect: replacement_glyph_info.uv_rect,
section_index, section_index,
}); });
@ -409,8 +417,12 @@ fn replace_last_glyph_with_overflow_character(
row.glyphs.push(Glyph { row.glyphs.push(Glyph {
chr: overflow_character, chr: overflow_character,
pos: pos2(x, f32::NAN), pos: pos2(x, f32::NAN),
size: vec2(replacement_glyph_info.advance_width, line_height), advance_width: replacement_glyph_info.advance_width,
ascent: font_impl.map_or(0.0, |font| font.ascent()), // Failure to find the font here would be weird line_height,
font_impl_height: font_impl.map_or(0.0, |f| f.row_height()),
font_impl_ascent: font_impl.map_or(0.0, |f| f.ascent()),
font_height: font.row_height(),
font_ascent: font.ascent(),
uv_rect: replacement_glyph_info.uv_rect, uv_rect: replacement_glyph_info.uv_rect,
section_index, section_index,
}); });
@ -438,7 +450,6 @@ fn replace_last_glyph_with_overflow_character(
let section = &job.sections[last_glyph.section_index as usize]; let section = &job.sections[last_glyph.section_index as usize];
let extra_letter_spacing = section.format.extra_letter_spacing; let extra_letter_spacing = section.format.extra_letter_spacing;
let font = fonts.font(&section.format.font_id); let font = fonts.font(&section.format.font_id);
let line_height = row_height(section, font);
if let Some(prev_glyph) = prev_glyph { if let Some(prev_glyph) = prev_glyph {
let prev_glyph_id = font.font_impl_and_glyph_info(prev_glyph.chr).1.id; let prev_glyph_id = font.font_impl_and_glyph_info(prev_glyph.chr).1.id;
@ -453,7 +464,9 @@ fn replace_last_glyph_with_overflow_character(
// Replace the glyph: // Replace the glyph:
last_glyph.chr = overflow_character; last_glyph.chr = overflow_character;
let (font_impl, glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr); let (font_impl, glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr);
last_glyph.size = vec2(glyph_info.advance_width, line_height); last_glyph.advance_width = glyph_info.advance_width;
last_glyph.font_impl_ascent = font_impl.map_or(0.0, |f| f.ascent());
last_glyph.font_impl_height = font_impl.map_or(0.0, |f| f.row_height());
last_glyph.uv_rect = glyph_info.uv_rect; last_glyph.uv_rect = glyph_info.uv_rect;
// Reapply kerning: // Reapply kerning:
@ -472,8 +485,10 @@ fn replace_last_glyph_with_overflow_character(
} else { } else {
// Just replace and be done with it. // Just replace and be done with it.
last_glyph.chr = overflow_character; last_glyph.chr = overflow_character;
let (_, glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr); let (font_impl, glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr);
last_glyph.size = vec2(glyph_info.advance_width, line_height); last_glyph.advance_width = glyph_info.advance_width;
last_glyph.font_impl_ascent = font_impl.map_or(0.0, |f| f.ascent());
last_glyph.font_impl_height = font_impl.map_or(0.0, |f| f.row_height());
last_glyph.uv_rect = glyph_info.uv_rect; last_glyph.uv_rect = glyph_info.uv_rect;
return; return;
} }
@ -585,40 +600,36 @@ fn galley_from_rows(
let mut min_x: f32 = 0.0; let mut min_x: f32 = 0.0;
let mut max_x: f32 = 0.0; let mut max_x: f32 = 0.0;
for row in &mut rows { for row in &mut rows {
let mut line_height = first_row_min_height.max(row.rect.height()); let mut max_row_height = first_row_min_height.max(row.rect.height());
let mut row_ascent = 0.0f32;
first_row_min_height = 0.0; first_row_min_height = 0.0;
for glyph in &row.glyphs {
// take metrics from the highest font in this row max_row_height = max_row_height.max(glyph.line_height);
if let Some(glyph) = row
.glyphs
.iter()
.max_by(|a, b| a.size.y.partial_cmp(&b.size.y).unwrap())
{
line_height = glyph.size.y;
row_ascent = glyph.ascent;
} }
line_height = point_scale.round_to_pixel(line_height); max_row_height = point_scale.round_to_pixel(max_row_height);
// Now positions each glyph: // Now position each glyph vertically:
for glyph in &mut row.glyphs { for glyph in &mut row.glyphs {
let format = &job.sections[glyph.section_index as usize].format; let format = &job.sections[glyph.section_index as usize].format;
let align_offset = match format.valign { glyph.pos.y = cursor_y
Align::Center | Align::Max => row_ascent, + glyph.font_impl_ascent
// raised text. // Apply valign to the different in height of the entire row, and the height of this `Font`:
Align::Min => glyph.ascent, + format.valign.to_factor() * (max_row_height - glyph.line_height)
};
glyph.pos.y = cursor_y + align_offset; // When mixing different `FontImpl` (e.g. latin and emojis),
// we always center the difference:
+ 0.5 * (glyph.font_height - glyph.font_impl_height);
glyph.pos.y = point_scale.round_to_pixel(glyph.pos.y);
} }
row.rect.min.y = cursor_y; row.rect.min.y = cursor_y;
row.rect.max.y = cursor_y + line_height; row.rect.max.y = cursor_y + max_row_height;
min_x = min_x.min(row.rect.min.x); min_x = min_x.min(row.rect.min.x);
max_x = max_x.max(row.rect.max.x); max_x = max_x.max(row.rect.max.x);
cursor_y += line_height; cursor_y += max_row_height;
cursor_y = point_scale.round_to_pixel(cursor_y); cursor_y = point_scale.round_to_pixel(cursor_y);
} }

View File

@ -279,8 +279,14 @@ pub struct TextFormat {
/// If you use a small font and [`Align::TOP`] you /// If you use a small font and [`Align::TOP`] you
/// can get the effect of raised text. /// can get the effect of raised text.
///
/// If you use a small font and [`Align::BOTTOM`]
/// you get the effect of a subscript.
///
/// If you use [`Align::Center`], you get text that is centered
/// around a common center-line, which is nice when mixining emojis
/// and normal text in e.g. a button.
pub valign: Align, pub valign: Align,
// TODO(emilk): lowered
} }
impl Default for TextFormat { impl Default for TextFormat {
@ -602,13 +608,26 @@ pub struct Glyph {
/// Logical position: pos.y is the same for all chars of the same [`TextFormat`]. /// Logical position: pos.y is the same for all chars of the same [`TextFormat`].
pub pos: Pos2, pub pos: Pos2,
/// `ascent` value from the font /// Logical width of the glyph.
pub ascent: f32, pub advance_width: f32,
/// Advance width and line height. /// Height of this row of text.
/// ///
/// Does not control the visual size of the glyph (see [`Self::uv_rect`] for that). /// Usually same as [`Self::font_height`],
pub size: Vec2, /// unless explicitly overridden by [`TextFormat::line_height`].
pub line_height: f32,
/// The ascent of this font.
pub font_ascent: f32,
/// The row/line height of this font.
pub font_height: f32,
/// The ascent of the sub-font within the font ("FontImpl").
pub font_impl_ascent: f32,
/// The row/line height of the sub-font within the font ("FontImpl").
pub font_impl_height: f32,
/// Position and size of the glyph in the font texture, in texels. /// Position and size of the glyph in the font texture, in texels.
pub uv_rect: UvRect, pub uv_rect: UvRect,
@ -618,14 +637,20 @@ pub struct Glyph {
} }
impl Glyph { impl Glyph {
#[inline]
pub fn size(&self) -> Vec2 {
Vec2::new(self.advance_width, self.line_height)
}
#[inline]
pub fn max_x(&self) -> f32 { pub fn max_x(&self) -> f32 {
self.pos.x + self.size.x self.pos.x + self.advance_width
} }
/// Same y range for all characters with the same [`TextFormat`]. /// Same y range for all characters with the same [`TextFormat`].
#[inline] #[inline]
pub fn logical_rect(&self) -> Rect { pub fn logical_rect(&self) -> Rect {
Rect::from_min_size(self.pos - vec2(0.0, self.ascent), self.size) Rect::from_min_size(self.pos - vec2(0.0, self.font_ascent), self.size())
} }
} }