From 33a0f50f6a80fe28a6430de7c70d6da79ebba2d5 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Thu, 21 Sep 2023 10:41:49 +0200 Subject: [PATCH 1/6] Improve text truncation: always include elision character (#3366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Row::text * Rename elide_at_width -> truncate_at_width * Move text layout tests to own module * Add test to check that elision character is always included * Include elision character in more circumstances * Append overflow character if we can't replace * Always append … when eliding * Add a secondary text to the text layout demo --- crates/egui_demo_lib/src/demo/text_layout.rs | 68 ++-- crates/epaint/src/text/text_layout.rs | 347 +++++++++++++------ crates/epaint/src/text/text_layout_types.rs | 14 +- 3 files changed, 300 insertions(+), 129 deletions(-) diff --git a/crates/egui_demo_lib/src/demo/text_layout.rs b/crates/egui_demo_lib/src/demo/text_layout.rs index 01a5c7787..f2512ae8c 100644 --- a/crates/egui_demo_lib/src/demo/text_layout.rs +++ b/crates/egui_demo_lib/src/demo/text_layout.rs @@ -7,16 +7,18 @@ pub struct TextLayoutDemo { overflow_character: Option, extra_letter_spacing_pixels: i32, line_height_pixels: u32, + lorem_ipsum: bool, } impl Default for TextLayoutDemo { fn default() -> Self { Self { - max_rows: 3, + max_rows: 6, break_anywhere: true, overflow_character: Some('…'), extra_letter_spacing_pixels: 0, line_height_pixels: 0, + lorem_ipsum: true, } } } @@ -45,6 +47,7 @@ impl super::View for TextLayoutDemo { overflow_character, extra_letter_spacing_pixels, line_height_pixels, + lorem_ipsum, } = self; use egui::text::LayoutJob; @@ -104,32 +107,55 @@ impl super::View for TextLayoutDemo { } }); ui.end_row(); + + ui.label("Text:"); + ui.horizontal(|ui| { + ui.selectable_value(lorem_ipsum, true, "Lorem Ipsum"); + ui.selectable_value(lorem_ipsum, false, "La Pasionaria"); + }); }); ui.add_space(12.0); - egui::ScrollArea::vertical().show(ui, |ui| { - let extra_letter_spacing = points_per_pixel * *extra_letter_spacing_pixels as f32; - let line_height = - (*line_height_pixels != 0).then_some(points_per_pixel * *line_height_pixels as f32); + let text = if *lorem_ipsum { + crate::LOREM_IPSUM_LONG + } else { + TO_BE_OR_NOT_TO_BE + }; - let mut job = LayoutJob::single_section( - crate::LOREM_IPSUM_LONG.to_owned(), - egui::TextFormat { - extra_letter_spacing, - line_height, + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + let extra_letter_spacing = points_per_pixel * *extra_letter_spacing_pixels as f32; + let line_height = (*line_height_pixels != 0) + .then_some(points_per_pixel * *line_height_pixels as f32); + + let mut job = LayoutJob::single_section( + text.to_owned(), + egui::TextFormat { + extra_letter_spacing, + line_height, + ..Default::default() + }, + ); + job.wrap = egui::text::TextWrapping { + max_rows: *max_rows, + break_anywhere: *break_anywhere, + overflow_character: *overflow_character, ..Default::default() - }, - ); - job.wrap = egui::text::TextWrapping { - max_rows: *max_rows, - break_anywhere: *break_anywhere, - overflow_character: *overflow_character, - ..Default::default() - }; + }; - // NOTE: `Label` overrides some of the wrapping settings, e.g. wrap width - ui.label(job); - }); + // NOTE: `Label` overrides some of the wrapping settings, e.g. wrap width + ui.label(job); + }); } } + +/// Excerpt from Dolores Ibárruri's farwel speech to the International Brigades: +const TO_BE_OR_NOT_TO_BE: &str = "Mothers! Women!\n +When the years pass by and the wounds of war are stanched; when the memory of the sad and bloody days dissipates in a present of liberty, of peace and of wellbeing; when the rancor have died out and pride in a free country is felt equally by all Spaniards, speak to your children. Tell them of these men of the International Brigades.\n\ +\n\ +Recount for them how, coming over seas and mountains, crossing frontiers bristling with bayonets, sought by raving dogs thirsting to tear their flesh, these men reached our country as crusaders for freedom, to fight and die for Spain’s liberty and independence threatened by German and Italian fascism. \ +They gave up everything — their loves, their countries, home and fortune, fathers, mothers, wives, brothers, sisters and children — and they came and said to us: “We are here. Your cause, Spain’s cause, is ours. It is the cause of all advanced and progressive mankind.”\n\ +\n\ +- Dolores Ibárruri, 1938"; diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index 860c19f8f..e36ee1eaa 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use emath::*; -use crate::{Color32, Mesh, Stroke, Vertex}; +use crate::{text::font::Font, Color32, Mesh, Stroke, Vertex}; use super::{FontsImpl, Galley, Glyph, LayoutJob, LayoutSection, Row, RowVisuals}; @@ -40,17 +40,31 @@ impl PointScale { // ---------------------------------------------------------------------------- /// Temporary storage before line-wrapping. -#[derive(Default, Clone)] +#[derive(Clone)] struct Paragraph { /// Start of the next glyph to be added. pub cursor_x: f32, + /// This is included in case there are no glyphs + pub section_index_at_start: u32, + pub glyphs: Vec, /// In case of an empty paragraph ("\n"), use this as height. pub empty_paragraph_height: f32, } +impl Paragraph { + pub fn from_section_index(section_index_at_start: u32) -> Self { + Self { + cursor_x: 0.0, + section_index_at_start, + glyphs: vec![], + empty_paragraph_height: 0.0, + } + } +} + /// Layout text into a [`Galley`]. /// /// In most cases you should use [`crate::Fonts::layout_job`] instead @@ -70,7 +84,9 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc) -> Galley { }; } - let mut paragraphs = vec![Paragraph::default()]; + // For most of this we ignore the y coordinate: + + let mut paragraphs = vec![Paragraph::from_section_index(0)]; for (section_index, section) in job.sections.iter().enumerate() { layout_section(fonts, &job, section_index as u32, section, &mut paragraphs); } @@ -78,7 +94,12 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc) -> Galley { let point_scale = PointScale::new(fonts.pixels_per_point()); let mut elided = false; - let mut rows = rows_from_paragraphs(fonts, paragraphs, &job, &mut elided); + let mut rows = rows_from_paragraphs(paragraphs, &job, &mut elided); + if elided { + if let Some(last_row) = rows.last_mut() { + replace_last_glyph_with_overflow_character(fonts, &job, last_row); + } + } let justify = job.justify && job.wrap.max_width.is_finite(); @@ -97,9 +118,11 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc) -> Galley { } } + // Calculate the Y positions and tessellate the text: galley_from_rows(point_scale, job, rows, elided) } +// Ignores the Y coordinate. fn layout_section( fonts: &mut FontsImpl, job: &LayoutJob, @@ -130,7 +153,7 @@ fn layout_section( for chr in job.text[byte_range.clone()].chars() { if job.break_on_newline && chr == '\n' { - out_paragraphs.push(Paragraph::default()); + out_paragraphs.push(Paragraph::from_section_index(section_index)); paragraph = out_paragraphs.last_mut().unwrap(); paragraph.empty_paragraph_height = line_height; // TODO(emilk): replace this hack with actually including `\n` in the glyphs? } else { @@ -163,8 +186,8 @@ fn rect_from_x_range(x_range: RangeInclusive) -> Rect { Rect::from_x_y_ranges(x_range, 0.0..=0.0) } +// Ignores the Y coordinate. fn rows_from_paragraphs( - fonts: &mut FontsImpl, paragraphs: Vec, job: &LayoutJob, elided: &mut bool, @@ -183,6 +206,7 @@ fn rows_from_paragraphs( if paragraph.glyphs.is_empty() { rows.push(Row { + section_index_at_start: paragraph.section_index_at_start, glyphs: vec![], visuals: Default::default(), rect: Rect::from_min_size( @@ -197,13 +221,14 @@ fn rows_from_paragraphs( // Early-out optimization: the whole paragraph fits on one row. let paragraph_min_x = paragraph.glyphs[0].pos.x; rows.push(Row { + section_index_at_start: paragraph.section_index_at_start, glyphs: paragraph.glyphs, visuals: Default::default(), rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x), ends_with_newline: !is_last_paragraph, }); } else { - line_break(fonts, ¶graph, job, &mut rows, elided); + line_break(¶graph, job, &mut rows, elided); rows.last_mut().unwrap().ends_with_newline = !is_last_paragraph; } } @@ -212,13 +237,7 @@ fn rows_from_paragraphs( rows } -fn line_break( - fonts: &mut FontsImpl, - paragraph: &Paragraph, - job: &LayoutJob, - out_rows: &mut Vec, - elided: &mut bool, -) { +fn line_break(paragraph: &Paragraph, job: &LayoutJob, out_rows: &mut Vec, elided: &mut bool) { // Keeps track of good places to insert row break if we exceed `wrap_width`. let mut row_break_candidates = RowBreakCandidates::default(); @@ -227,13 +246,13 @@ fn line_break( let mut row_start_idx = 0; for i in 0..paragraph.glyphs.len() { - let potential_row_width = paragraph.glyphs[i].max_x() - row_start_x; - if job.wrap.max_rows <= out_rows.len() { *elided = true; break; } + let potential_row_width = paragraph.glyphs[i].max_x() - row_start_x; + if job.wrap.max_width < potential_row_width { // Row break: @@ -243,6 +262,7 @@ fn line_break( // Allow the first row to be completely empty, because we know there will be more space on the next row: // TODO(emilk): this records the height of this first row as zero, though that is probably fine since first_row_indentation usually comes with a first_row_min_height. out_rows.push(Row { + section_index_at_start: paragraph.section_index_at_start, glyphs: vec![], visuals: Default::default(), rect: rect_from_x_range(first_row_indentation..=first_row_indentation), @@ -261,10 +281,12 @@ fn line_break( }) .collect(); + let section_index_at_start = glyphs[0].section_index; let paragraph_min_x = glyphs[0].pos.x; let paragraph_max_x = glyphs.last().unwrap().max_x(); out_rows.push(Row { + section_index_at_start, glyphs, visuals: Default::default(), rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x), @@ -287,10 +309,7 @@ fn line_break( // Final row of text: if job.wrap.max_rows <= out_rows.len() { - if let Some(last_row) = out_rows.last_mut() { - replace_last_glyph_with_overflow_character(fonts, job, last_row); - *elided = true; - } + *elided = true; // can't fit another row } else { let glyphs: Vec = paragraph.glyphs[row_start_idx..] .iter() @@ -301,10 +320,12 @@ fn line_break( }) .collect(); + let section_index_at_start = glyphs[0].section_index; let paragraph_min_x = glyphs[0].pos.x; let paragraph_max_x = glyphs.last().unwrap().max_x(); out_rows.push(Row { + section_index_at_start, glyphs, visuals: Default::default(), rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x), @@ -315,76 +336,148 @@ fn line_break( } /// Trims the last glyphs in the row and replaces it with an overflow character (e.g. `…`). +/// +/// Called before we have any Y coordinates. fn replace_last_glyph_with_overflow_character( fonts: &mut FontsImpl, job: &LayoutJob, row: &mut Row, ) { + fn row_width(row: &Row) -> f32 { + if let (Some(first), Some(last)) = (row.glyphs.first(), row.glyphs.last()) { + last.max_x() - first.pos.x + } else { + 0.0 + } + } + + fn row_height(section: &LayoutSection, font: &Font) -> f32 { + section + .format + .line_height + .unwrap_or_else(|| font.row_height()) + } + let Some(overflow_character) = job.wrap.overflow_character else { return; }; + // We always try to just append the character first: + if let Some(last_glyph) = row.glyphs.last() { + let section_index = last_glyph.section_index; + let section = &job.sections[section_index as usize]; + let font = fonts.font(§ion.format.font_id); + let line_height = row_height(section, font); + + 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 (font_impl, replacement_glyph_info) = font.font_impl_and_glyph_info(overflow_character); + + { + // Kerning: + x += section.format.extra_letter_spacing; + if let Some(font_impl) = font_impl { + x += font_impl.pair_kerning(last_glyph_info.id, replacement_glyph_info.id); + } + } + + row.glyphs.push(Glyph { + chr: overflow_character, + pos: pos2(x, f32::NAN), + size: vec2(replacement_glyph_info.advance_width, line_height), + ascent: font_impl.map_or(0.0, |font| font.ascent()), // Failure to find the font here would be weird + uv_rect: replacement_glyph_info.uv_rect, + section_index, + }); + } else { + let section_index = row.section_index_at_start; + let section = &job.sections[section_index as usize]; + let font = fonts.font(§ion.format.font_id); + let line_height = row_height(section, font); + + let x = 0.0; // TODO(emilk): heed paragraph leading_space 😬 + + let (font_impl, replacement_glyph_info) = font.font_impl_and_glyph_info(overflow_character); + + row.glyphs.push(Glyph { + chr: overflow_character, + pos: pos2(x, f32::NAN), + size: vec2(replacement_glyph_info.advance_width, line_height), + ascent: font_impl.map_or(0.0, |font| font.ascent()), // Failure to find the font here would be weird + uv_rect: replacement_glyph_info.uv_rect, + section_index, + }); + } + + if row_width(row) <= job.wrap.max_width || row.glyphs.len() == 1 { + return; // we are done + } + + // We didn't fit it. Remove it again… + row.glyphs.pop(); + + // …then go into a loop where we replace the last character with the overflow character + // until we fit within the max_width: + loop { let (prev_glyph, last_glyph) = match row.glyphs.as_mut_slice() { [.., prev, last] => (Some(prev), last), [.., last] => (None, last), - _ => break, + _ => { + unreachable!("We've already explicitly handled the empty row"); + } }; let section = &job.sections[last_glyph.section_index as usize]; let extra_letter_spacing = section.format.extra_letter_spacing; let font = fonts.font(§ion.format.font_id); - let line_height = section - .format - .line_height - .unwrap_or_else(|| font.row_height()); + let line_height = row_height(section, font); - let prev_glyph_id = prev_glyph.map(|prev_glyph| { - let (_, prev_glyph_info) = font.font_impl_and_glyph_info(prev_glyph.chr); - prev_glyph_info.id - }); + if let Some(prev_glyph) = prev_glyph { + let prev_glyph_id = font.font_impl_and_glyph_info(prev_glyph.chr).1.id; - // undo kerning with previous glyph - let (font_impl, glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr); - last_glyph.pos.x -= extra_letter_spacing - + font_impl - .zip(prev_glyph_id) - .map(|(font_impl, prev_glyph_id)| { - font_impl.pair_kerning(prev_glyph_id, glyph_info.id) - }) - .unwrap_or_default(); + // Undo kerning with previous glyph: + let (font_impl, glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr); + last_glyph.pos.x -= extra_letter_spacing; + if let Some(font_impl) = font_impl { + last_glyph.pos.x -= font_impl.pair_kerning(prev_glyph_id, glyph_info.id); + } - // replace the glyph - last_glyph.chr = overflow_character; - 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.uv_rect = glyph_info.uv_rect; + // Replace the glyph: + last_glyph.chr = overflow_character; + 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.uv_rect = glyph_info.uv_rect; - // reapply kerning - last_glyph.pos.x += extra_letter_spacing - + font_impl - .zip(prev_glyph_id) - .map(|(font_impl, prev_glyph_id)| { - font_impl.pair_kerning(prev_glyph_id, glyph_info.id) - }) - .unwrap_or_default(); + // Reapply kerning: + last_glyph.pos.x += extra_letter_spacing; + if let Some(font_impl) = font_impl { + last_glyph.pos.x += font_impl.pair_kerning(prev_glyph_id, glyph_info.id); + } - row.rect.max.x = last_glyph.max_x(); + // Check if we're within width budget: + if row_width(row) <= job.wrap.max_width || row.glyphs.len() == 1 { + return; // We are done + } - // check if we're within width budget - let row_end_x = last_glyph.max_x(); - let row_start_x = row.glyphs.first().unwrap().pos.x; // if `last_mut()` returned `Some`, then so will `first()` - let row_width = row_end_x - row_start_x; - if row_width <= job.wrap.max_width { - return; // we are done + // We didn't fit - pop the last glyph and try again. + row.glyphs.pop(); + } else { + // Just replace and be done with it. + last_glyph.chr = overflow_character; + let (_, glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr); + last_glyph.size = vec2(glyph_info.advance_width, line_height); + last_glyph.uv_rect = glyph_info.uv_rect; + return; } - - row.glyphs.pop(); } - - // We failed to insert `overflow_character` without exceeding `wrap_width`. } +/// Horizontally aligned the text on a row. +/// +/// /// Ignores the Y coordinate. fn halign_and_justify_row( point_scale: PointScale, row: &mut Row, @@ -879,49 +972,93 @@ fn is_cjk_break_allowed(c: char) -> bool { // ---------------------------------------------------------------------------- -#[test] -fn test_zero_max_width() { - let mut fonts = FontsImpl::new(1.0, 1024, super::FontDefinitions::default()); - let mut layout_job = LayoutJob::single_section("W".into(), super::TextFormat::default()); - layout_job.wrap.max_width = 0.0; - let galley = super::layout(&mut fonts, layout_job.into()); - assert_eq!(galley.rows.len(), 1); -} +#[cfg(test)] +mod tests { + use super::{super::*, *}; -#[test] -fn test_cjk() { - let mut fonts = FontsImpl::new(1.0, 1024, super::FontDefinitions::default()); - let mut layout_job = LayoutJob::single_section( - "日本語とEnglishの混在した文章".into(), - super::TextFormat::default(), - ); - layout_job.wrap.max_width = 90.0; - let galley = super::layout(&mut fonts, layout_job.into()); - assert_eq!( - galley - .rows - .iter() - .map(|row| row.glyphs.iter().map(|g| g.chr).collect::()) - .collect::>(), - vec!["日本語と", "Englishの混在", "した文章"] - ); -} + #[test] + fn test_zero_max_width() { + let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); + let mut layout_job = LayoutJob::single_section("W".into(), TextFormat::default()); + layout_job.wrap.max_width = 0.0; + let galley = layout(&mut fonts, layout_job.into()); + assert_eq!(galley.rows.len(), 1); + } -#[test] -fn test_pre_cjk() { - let mut fonts = FontsImpl::new(1.0, 1024, super::FontDefinitions::default()); - let mut layout_job = LayoutJob::single_section( - "日本語とEnglishの混在した文章".into(), - super::TextFormat::default(), - ); - layout_job.wrap.max_width = 110.0; - let galley = super::layout(&mut fonts, layout_job.into()); - assert_eq!( - galley - .rows - .iter() - .map(|row| row.glyphs.iter().map(|g| g.chr).collect::()) - .collect::>(), - vec!["日本語とEnglish", "の混在した文章"] - ); + #[test] + fn test_truncate_with_newline() { + // No matter where we wrap, we should be appending the newline character. + + let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); + let text_format = TextFormat { + font_id: FontId::monospace(12.0), + ..Default::default() + }; + + for text in ["Hello\nworld", "\nfoo"] { + for break_anywhere in [false, true] { + for max_width in [0.0, 5.0, 10.0, 20.0, f32::INFINITY] { + let mut layout_job = + LayoutJob::single_section(text.into(), text_format.clone()); + layout_job.wrap.max_width = max_width; + layout_job.wrap.max_rows = 1; + layout_job.wrap.break_anywhere = break_anywhere; + + let galley = layout(&mut fonts, layout_job.into()); + + assert!(galley.elided); + assert_eq!(galley.rows.len(), 1); + let row_text = galley.rows[0].text(); + assert!( + row_text.ends_with('…'), + "Expected row to end with `…`, got {row_text:?} when line-breaking the text {text:?} with max_width {max_width} and break_anywhere {break_anywhere}.", + ); + } + } + } + + { + let mut layout_job = LayoutJob::single_section("Hello\nworld".into(), text_format); + layout_job.wrap.max_width = 50.0; + layout_job.wrap.max_rows = 1; + layout_job.wrap.break_anywhere = false; + + let galley = layout(&mut fonts, layout_job.into()); + + assert!(galley.elided); + assert_eq!(galley.rows.len(), 1); + let row_text = galley.rows[0].text(); + assert_eq!(row_text, "Hello…"); + } + } + + #[test] + fn test_cjk() { + let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); + let mut layout_job = LayoutJob::single_section( + "日本語とEnglishの混在した文章".into(), + TextFormat::default(), + ); + layout_job.wrap.max_width = 90.0; + let galley = layout(&mut fonts, layout_job.into()); + assert_eq!( + galley.rows.iter().map(|row| row.text()).collect::>(), + vec!["日本語と", "Englishの混在", "した文章"] + ); + } + + #[test] + fn test_pre_cjk() { + let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); + let mut layout_job = LayoutJob::single_section( + "日本語とEnglishの混在した文章".into(), + TextFormat::default(), + ); + layout_job.wrap.max_width = 110.0; + let galley = layout(&mut fonts, layout_job.into()); + assert_eq!( + galley.rows.iter().map(|row| row.text()).collect::>(), + vec!["日本語とEnglish", "の混在した文章"] + ); + } } diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 12c625ccd..7b81cd824 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -394,7 +394,7 @@ impl Default for TextWrapping { } impl TextWrapping { - /// A row can be as long as it need to be + /// A row can be as long as it need to be. pub fn no_max_width() -> Self { Self { max_width: f32::INFINITY, @@ -402,8 +402,8 @@ impl TextWrapping { } } - /// Elide text that doesn't fit within the given width. - pub fn elide_at_width(max_width: f32) -> Self { + /// Elide text that doesn't fit within the given width, replaced with `…`. + pub fn truncate_at_width(max_width: f32) -> Self { Self { max_width, max_rows: 1, @@ -475,6 +475,9 @@ pub struct Galley { #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct Row { + /// This is included in case there are no glyphs + pub section_index_at_start: u32, + /// One for each `char`. pub glyphs: Vec, @@ -561,6 +564,11 @@ impl Glyph { // ---------------------------------------------------------------------------- impl Row { + /// The text on this row, excluding the implicit `\n` if any. + pub fn text(&self) -> String { + self.glyphs.iter().map(|g| g.chr).collect() + } + /// Excludes the implicit `\n` after the [`Row`], if any. #[inline] pub fn char_count_excluding_newline(&self) -> usize { From d77c44657206d6a5a9eda0d3b10fac6e189acc27 Mon Sep 17 00:00:00 2001 From: YgorSouza <43298013+YgorSouza@users.noreply.github.com> Date: Sat, 23 Sep 2023 15:27:34 +0200 Subject: [PATCH 2/6] Prevent text from being cleared when selected due to winit IME (#3376) Closes #3374 --- crates/egui-winit/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index 079a24048..f9feae9a4 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -291,7 +291,7 @@ impl State { .events .push(egui::Event::CompositionEnd(text.clone())); } - winit::event::Ime::Preedit(text, ..) => { + winit::event::Ime::Preedit(text, Some(_)) => { if !self.input_method_editor_started { self.input_method_editor_started = true; self.egui_input.events.push(egui::Event::CompositionStart); @@ -300,6 +300,7 @@ impl State { .events .push(egui::Event::CompositionUpdate(text.clone())); } + winit::event::Ime::Preedit(_, None) => {} }; EventResponse { From 99a1b5b62e60b2a2b3cb5728cc57108669242855 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sun, 24 Sep 2023 09:31:21 +0200 Subject: [PATCH 3/6] Add `Context::open_url` and `Context::copy_text` (#3380) * Add Context::open_url * Add `Context::copy_text` * Fix doctest * Fix another doctest --- crates/egui/src/context.rs | 29 +++++++++++++++++--- crates/egui/src/data/output.rs | 4 +++ crates/egui/src/lib.rs | 4 ++- crates/egui/src/widgets/color_picker.rs | 4 +-- crates/egui/src/widgets/hyperlink.rs | 17 +++++------- crates/egui/src/widgets/text_edit/builder.rs | 2 +- crates/egui_demo_app/src/apps/http_app.rs | 2 +- crates/egui_demo_app/src/wrap_app.rs | 5 ++-- crates/egui_demo_lib/src/demo/font_book.rs | 2 +- examples/puffin_profiler/src/main.rs | 2 +- 10 files changed, 48 insertions(+), 23 deletions(-) diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index f4e520cea..f78101ff2 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -551,10 +551,6 @@ impl Context { ) -> R { self.write(move |ctx| writer(&mut ctx.memory.options.tessellation_options)) } -} - -impl Context { - // --------------------------------------------------------------------- /// If the given [`Id`] has been used previously the same frame at at different position, /// then an error will be printed on screen. @@ -919,6 +915,31 @@ impl Context { self.output_mut(|o| o.cursor_icon = cursor_icon); } + /// Open an URL in a browser. + /// + /// Equivalent to: + /// ``` + /// # let ctx = egui::Context::default(); + /// # let open_url = egui::OpenUrl::same_tab("http://www.example.com"); + /// ctx.output_mut(|o| o.open_url = Some(open_url)); + /// ``` + pub fn open_url(&self, open_url: crate::OpenUrl) { + self.output_mut(|o| o.open_url = Some(open_url)); + } + + /// Copy the given text to the system clipboard. + /// + /// Empty strings are ignored. + /// + /// Equivalent to: + /// ``` + /// # let ctx = egui::Context::default(); + /// ctx.output_mut(|o| o.copied_text = "Copy this".to_owned()); + /// ``` + pub fn copy_text(&self, text: String) { + self.output_mut(|o| o.copied_text = text); + } + /// Format the given shortcut in a human-readable way (e.g. `Ctrl+Shift+X`). /// /// Can be used to get the text for [`Button::shortcut_text`]. diff --git a/crates/egui/src/data/output.rs b/crates/egui/src/data/output.rs index 156a34928..ddb9b2864 100644 --- a/crates/egui/src/data/output.rs +++ b/crates/egui/src/data/output.rs @@ -92,7 +92,9 @@ pub struct PlatformOutput { impl PlatformOutput { /// Open the given url in a web browser. + /// /// If egui is running in a browser, the same tab will be reused. + #[deprecated = "Use Context::open_url instead"] pub fn open_url(&mut self, url: impl ToString) { self.open_url = Some(OpenUrl::same_tab(url)); } @@ -156,6 +158,8 @@ impl PlatformOutput { } /// What URL to open, and how. +/// +/// Use with [`crate::Context::open_url`]. #[derive(Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct OpenUrl { diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 68d798075..15020c333 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -389,7 +389,9 @@ pub use { context::{Context, RequestRepaintInfo}, data::{ input::*, - output::{self, CursorIcon, FullOutput, PlatformOutput, UserAttentionType, WidgetInfo}, + output::{ + self, CursorIcon, FullOutput, OpenUrl, PlatformOutput, UserAttentionType, WidgetInfo, + }, }, grid::Grid, id::{Id, IdMap}, diff --git a/crates/egui/src/widgets/color_picker.rs b/crates/egui/src/widgets/color_picker.rs index fcb701f47..aed954793 100644 --- a/crates/egui/src/widgets/color_picker.rs +++ b/crates/egui/src/widgets/color_picker.rs @@ -234,9 +234,9 @@ fn color_text_ui(ui: &mut Ui, color: impl Into, alpha: Alpha) { if ui.button("📋").on_hover_text("Click to copy").clicked() { if alpha == Alpha::Opaque { - ui.output_mut(|o| o.copied_text = format!("{r}, {g}, {b}")); + ui.ctx().copy_text(format!("{r}, {g}, {b}")); } else { - ui.output_mut(|o| o.copied_text = format!("{r}, {g}, {b}, {a}")); + ui.ctx().copy_text(format!("{r}, {g}, {b}, {a}")); } } diff --git a/crates/egui/src/widgets/hyperlink.rs b/crates/egui/src/widgets/hyperlink.rs index 0ef0ee488..7017d7463 100644 --- a/crates/egui/src/widgets/hyperlink.rs +++ b/crates/egui/src/widgets/hyperlink.rs @@ -118,21 +118,18 @@ impl Widget for Hyperlink { let Self { url, text, new_tab } = self; let response = ui.add(Link::new(text)); + if response.clicked() { let modifiers = ui.ctx().input(|i| i.modifiers); - ui.ctx().output_mut(|o| { - o.open_url = Some(crate::output::OpenUrl { - url: url.clone(), - new_tab: new_tab || modifiers.any(), - }); + ui.ctx().open_url(crate::OpenUrl { + url: url.clone(), + new_tab: new_tab || modifiers.any(), }); } if response.middle_clicked() { - ui.ctx().output_mut(|o| { - o.open_url = Some(crate::output::OpenUrl { - url: url.clone(), - new_tab: true, - }); + ui.ctx().open_url(crate::OpenUrl { + url: url.clone(), + new_tab: true, }); } response.on_hover_text(url) diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index e19698977..f07beedea 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -900,7 +900,7 @@ fn events( let copy_if_not_password = |ui: &Ui, text: String| { if !password { - ui.ctx().output_mut(|o| o.copied_text = text); + ui.ctx().copy_text(text); } }; diff --git a/crates/egui_demo_app/src/apps/http_app.rs b/crates/egui_demo_app/src/apps/http_app.rs index ef5c2987a..90be24dd2 100644 --- a/crates/egui_demo_app/src/apps/http_app.rs +++ b/crates/egui_demo_app/src/apps/http_app.rs @@ -195,7 +195,7 @@ fn ui_resource(ui: &mut egui::Ui, resource: &Resource) { if let Some(text) = &text { let tooltip = "Click to copy the response body"; if ui.button("📋").on_hover_text(tooltip).clicked() { - ui.output_mut(|o| o.copied_text = text.clone()); + ui.ctx().copy_text(text.clone()); } ui.separator(); } diff --git a/crates/egui_demo_app/src/wrap_app.rs b/crates/egui_demo_app/src/wrap_app.rs index d2a0e9102..619e70ad8 100644 --- a/crates/egui_demo_app/src/wrap_app.rs +++ b/crates/egui_demo_app/src/wrap_app.rs @@ -396,7 +396,8 @@ impl WrapApp { { selected_anchor = anchor; if frame.is_web() { - ui.output_mut(|o| o.open_url(format!("#{anchor}"))); + ui.ctx() + .open_url(egui::OpenUrl::same_tab(format!("#{anchor}"))); } } } @@ -408,7 +409,7 @@ impl WrapApp { if clock_button(ui, crate::seconds_since_midnight()).clicked() { self.state.selected_anchor = Anchor::Clock; if frame.is_web() { - ui.output_mut(|o| o.open_url("#clock")); + ui.ctx().open_url(egui::OpenUrl::same_tab("#clock")); } } } diff --git a/crates/egui_demo_lib/src/demo/font_book.rs b/crates/egui_demo_lib/src/demo/font_book.rs index 2b4eebae0..4dda2d87b 100644 --- a/crates/egui_demo_lib/src/demo/font_book.rs +++ b/crates/egui_demo_lib/src/demo/font_book.rs @@ -93,7 +93,7 @@ impl super::View for FontBook { }; if ui.add(button).on_hover_ui(tooltip_ui).clicked() { - ui.output_mut(|o| o.copied_text = chr.to_string()); + ui.ctx().copy_text(chr.to_string()); } } } diff --git a/examples/puffin_profiler/src/main.rs b/examples/puffin_profiler/src/main.rs index ab0007156..18b7f6e16 100644 --- a/examples/puffin_profiler/src/main.rs +++ b/examples/puffin_profiler/src/main.rs @@ -28,7 +28,7 @@ impl eframe::App for MyApp { ui.horizontal(|ui| { ui.monospace(cmd); if ui.small_button("📋").clicked() { - ui.output_mut(|o| o.copied_text = cmd.into()); + ui.ctx().copy_text(cmd.into()); } }); From fdd493d48fd23047480ac5a0219fc5f206eb0dd3 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sun, 24 Sep 2023 09:32:31 +0200 Subject: [PATCH 4/6] Misc cleanup (#3381) * Give credit to recent big-time contributors in the main README.md * Better named profiling scopes * Document everything in memory.rs * Better doc-strings * Add a section about dependencies to the main README.md * Improve egui_extras docs * fix typos --- README.md | 20 +++++++++++++++++++- crates/eframe/src/native/run.rs | 4 ++-- crates/egui/src/data/output.rs | 2 ++ crates/egui/src/memory.rs | 14 ++++++++++++++ crates/egui_extras/README.md | 12 ++++++++++++ crates/egui_extras/src/datepicker/button.rs | 1 + crates/egui_extras/src/lib.rs | 2 +- crates/emath/src/rect.rs | 4 ++++ 8 files changed, 55 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f210ba255..03d39b94d 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ On Fedora Rawhide you need to run: * Extensible: [easy to write your own widgets for egui](https://github.com/emilk/egui/blob/master/crates/egui_demo_lib/src/demo/toggle_switch.rs) * Modular: You should be able to use small parts of egui and combine them in new ways * Safe: there is no `unsafe` code in egui -* Minimal dependencies: [`ab_glyph`](https://crates.io/crates/ab_glyph) [`ahash`](https://crates.io/crates/ahash) [`nohash-hasher`](https://crates.io/crates/nohash-hasher) [`parking_lot`](https://crates.io/crates/parking_lot) +* Minimal dependencies egui is *not* a framework. egui is a library you call into, not an environment you program for. @@ -99,6 +99,21 @@ egui is *not* a framework. egui is a library you call into, not an environment y * Native looking interface * Advanced and flexible layouts (that's fundamentally incompatible with immediate mode) +## Dependencies +`egui` has a minimal set of default dependencies: + +* [`ab_glyph`](https://crates.io/crates/ab_glyph) +* [`ahash`](https://crates.io/crates/ahash) +* [`nohash-hasher`](https://crates.io/crates/nohash-hasher) +* [`parking_lot`](https://crates.io/crates/parking_lot) + +Heavier dependencies are kept out of `egui`, even as opt-in. +No code that isn't fully Wasm-friendly is part of `egui`. + +To load images into `egui` you can use the official [`egui_extras`](https://github.com/emilk/egui/tree/master/crates/egui_extras) crate. + +[`eframe`](https://github.com/emilk/egui/tree/master/crates/eframe) on the other hand has a lot of dependencies, including [`winit`](https://crates.io/crates/winit), [`image`](https://crates.io/crates/image), graphics crates, clipboard crates, etc, + ## Who is egui for? egui aims to be the best choice when you want a simple way to create a GUI, or you want to add a GUI to a game engine. @@ -351,6 +366,9 @@ Notable contributions by: * [@t18b219k](https://github.com/t18b219k): [Port glow painter to web](https://github.com/emilk/egui/pull/868). * [@danielkeller](https://github.com/danielkeller): [`Context` refactor](https://github.com/emilk/egui/pull/1050). * [@MaximOsipenko](https://github.com/MaximOsipenko): [`Context` lock refactor](https://github.com/emilk/egui/pull/2625). +* [@mwcampbell](https://github.com/mwcampbell): [AccessKit](https://github.com/AccessKit/accesskit) [integration](https://github.com/emilk/egui/pull/2294). +* [@hasenbanck](https://github.com/hasenbanck), [@s-nie](https://github.com/s-nie), [@Wumpf](https://github.com/Wumpf): [`egui-wgpu`](https://github.com/emilk/egui/tree/master/crates/egui-wgpu) +* [@jprochazk](https://github.com/jprochazk): [egui image API](https://github.com/emilk/egui/issues/3291) * And [many more](https://github.com/emilk/egui/graphs/contributors?type=a). egui is licensed under [MIT](LICENSE-MIT) OR [Apache-2.0](LICENSE-APACHE). diff --git a/crates/eframe/src/native/run.rs b/crates/eframe/src/native/run.rs index db11e0f82..7631cd784 100644 --- a/crates/eframe/src/native/run.rs +++ b/crates/eframe/src/native/run.rs @@ -970,7 +970,7 @@ mod glow_integration { if window.is_minimized() == Some(true) { // On Mac, a minimized Window uses up all CPU: // https://github.com/emilk/egui/issues/325 - crate::profile_scope!("bg_sleep"); + crate::profile_scope!("minimized_sleep"); std::thread::sleep(std::time::Duration::from_millis(10)); } @@ -1417,7 +1417,7 @@ mod wgpu_integration { if window.is_minimized() == Some(true) { // On Mac, a minimized Window uses up all CPU: // https://github.com/emilk/egui/issues/325 - crate::profile_scope!("bg_sleep"); + crate::profile_scope!("minimized_sleep"); std::thread::sleep(std::time::Duration::from_millis(10)); } diff --git a/crates/egui/src/data/output.rs b/crates/egui/src/data/output.rs index ddb9b2864..2e60db0d7 100644 --- a/crates/egui/src/data/output.rs +++ b/crates/egui/src/data/output.rs @@ -84,6 +84,8 @@ pub struct PlatformOutput { pub mutable_text_under_cursor: bool, /// Screen-space position of text edit cursor (used for IME). + /// + /// Iff `Some`, the user is editing text. pub text_cursor_pos: Option, #[cfg(feature = "accesskit")] diff --git a/crates/egui/src/memory.rs b/crates/egui/src/memory.rs index be7a79a85..2c56d9672 100644 --- a/crates/egui/src/memory.rs +++ b/crates/egui/src/memory.rs @@ -1,3 +1,5 @@ +#![warn(missing_docs)] // Let's keep this file well-documented.` to memory.rs + use epaint::{emath::Rangef, vec2, Vec2}; use crate::{area, window, EventFilter, Id, IdMap, InputState, LayerId, Pos2, Rect, Style}; @@ -17,6 +19,7 @@ use crate::{area, window, EventFilter, Id, IdMap, InputState, LayerId, Pos2, Rec #[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "persistence", serde(default))] pub struct Memory { + /// Global egui options. pub options: Options, /// This map stores some superficial state for all widgets with custom [`Id`]s. @@ -627,21 +630,25 @@ impl Memory { self.interaction.focus.focused_widget = None; } + /// Is any widget being dragged? #[inline(always)] pub fn is_anything_being_dragged(&self) -> bool { self.interaction.drag_id.is_some() } + /// Is this specific widget being dragged? #[inline(always)] pub fn is_being_dragged(&self, id: Id) -> bool { self.interaction.drag_id == Some(id) } + /// Set which widget is being dragged. #[inline(always)] pub fn set_dragged_id(&mut self, id: Id) { self.interaction.drag_id = Some(id); } + /// Stop dragging any widget. #[inline(always)] pub fn stop_dragging(&mut self) { self.interaction.drag_id = None; @@ -663,22 +670,29 @@ impl Memory { /// Popups are things like combo-boxes, color pickers, menus etc. /// Only one can be be open at a time. impl Memory { + /// Is the given popup open? pub fn is_popup_open(&self, popup_id: Id) -> bool { self.popup == Some(popup_id) || self.everything_is_visible() } + /// Is any popup open? pub fn any_popup_open(&self) -> bool { self.popup.is_some() || self.everything_is_visible() } + /// Open the given popup, and close all other. pub fn open_popup(&mut self, popup_id: Id) { self.popup = Some(popup_id); } + /// Close the open popup, if any. pub fn close_popup(&mut self) { self.popup = None; } + /// Toggle the given popup between closed and open. + /// + /// Note: at most one popup can be open at one time. pub fn toggle_popup(&mut self, popup_id: Id) { if self.is_popup_open(popup_id) { self.close_popup(); diff --git a/crates/egui_extras/README.md b/crates/egui_extras/README.md index d49a594ea..e46176c95 100644 --- a/crates/egui_extras/README.md +++ b/crates/egui_extras/README.md @@ -7,3 +7,15 @@ ![Apache](https://img.shields.io/badge/license-Apache-blue.svg) This is a crate that adds some features on top top of [`egui`](https://github.com/emilk/egui). This crate is for experimental features, and features that require big dependencies that do not belong in `egui`. + +## Images +One thing `egui_extras` is commonly used for is to install image loaders for `egui`: + +```toml +egui_extras = { version = "*", features = ["all_loaders"] } +image = { version = "0.24", features = ["jpeg", "png"] } +``` + +```rs +egui_extras::install_image_loaders(egui_ctx); +``` diff --git a/crates/egui_extras/src/datepicker/button.rs b/crates/egui_extras/src/datepicker/button.rs index b63e5bc01..d40a7ebac 100644 --- a/crates/egui_extras/src/datepicker/button.rs +++ b/crates/egui_extras/src/datepicker/button.rs @@ -7,6 +7,7 @@ pub(crate) struct DatePickerButtonState { pub picker_visible: bool, } +/// Shows a date, and will open a date picker popup when clicked. pub struct DatePickerButton<'a> { selection: &'a mut NaiveDate, id_source: Option<&'a str>, diff --git a/crates/egui_extras/src/lib.rs b/crates/egui_extras/src/lib.rs index 64bd808be..e9206dd13 100644 --- a/crates/egui_extras/src/lib.rs +++ b/crates/egui_extras/src/lib.rs @@ -18,7 +18,7 @@ pub mod syntax_highlighting; #[doc(hidden)] pub mod image; mod layout; -pub mod loaders; +mod loaders; mod sizing; mod strip; mod table; diff --git a/crates/emath/src/rect.rs b/crates/emath/src/rect.rs index 1007c2197..f2d037cae 100644 --- a/crates/emath/src/rect.rs +++ b/crates/emath/src/rect.rs @@ -13,6 +13,10 @@ use crate::*; /// of `min` and `max` are swapped. These are usually a sign of an error. /// /// Normally the unit is points (logical pixels) in screen space coordinates. +/// +/// `Rect` does NOT implement `Default`, because there is no obvious default value. +/// [`Rect::ZERO`] may seem reasonable, but when used as a bounding box, [`Rect::NOTHING`] +/// is a better default - so be explicit instead! #[repr(C)] #[derive(Clone, Copy, Eq, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] From 8bf0055bda8bb0c25fe1f1a5d6348194bf6937bd Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 25 Sep 2023 15:45:32 +0200 Subject: [PATCH 5/6] Better handle additive colors in plots (#3387) --- crates/ecolor/src/color32.rs | 6 ++++++ crates/ecolor/src/rgba.rs | 6 ++++++ crates/egui_plot/src/items/rect_elem.rs | 14 +++++++++++--- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/ecolor/src/color32.rs b/crates/ecolor/src/color32.rs index 09e68116f..6f2c11272 100644 --- a/crates/ecolor/src/color32.rs +++ b/crates/ecolor/src/color32.rs @@ -155,6 +155,12 @@ impl Color32 { Self([r, g, b, 0]) } + /// Is the alpha=0 ? + #[inline(always)] + pub fn is_additive(self) -> bool { + self.a() == 0 + } + /// Premultiplied RGBA #[inline(always)] pub const fn to_array(&self) -> [u8; 4] { diff --git a/crates/ecolor/src/rgba.rs b/crates/ecolor/src/rgba.rs index 38bbaa321..849696ddc 100644 --- a/crates/ecolor/src/rgba.rs +++ b/crates/ecolor/src/rgba.rs @@ -122,6 +122,12 @@ impl Rgba { Self([r, g, b, 0.0]) } + /// Is the alpha=0 ? + #[inline(always)] + pub fn is_additive(self) -> bool { + self.a() == 0.0 + } + /// Multiply with e.g. 0.5 to make us half transparent #[inline(always)] pub fn multiply(self, alpha: f32) -> Self { diff --git a/crates/egui_plot/src/items/rect_elem.rs b/crates/egui_plot/src/items/rect_elem.rs index 1ac470c77..b83da7c3d 100644 --- a/crates/egui_plot/src/items/rect_elem.rs +++ b/crates/egui_plot/src/items/rect_elem.rs @@ -58,8 +58,16 @@ pub(super) trait RectElement { pub(super) fn highlighted_color(mut stroke: Stroke, fill: Color32) -> (Stroke, Color32) { stroke.width *= 2.0; - let fill = Rgba::from(fill); - let fill_alpha = (2.0 * fill.a()).at_most(1.0); - let fill = fill.to_opaque().multiply(fill_alpha); + + let mut fill = Rgba::from(fill); + if fill.is_additive() { + // Make slightly brighter + fill = 1.3 * fill; + } else { + // Make more opaque: + let fill_alpha = (2.0 * fill.a()).at_most(1.0); + fill = fill.to_opaque().multiply(fill_alpha); + } + (stroke, fill.into()) } From e8986b1e59e60fa97349754b160dd68a34ab2093 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 25 Sep 2023 17:48:22 +0200 Subject: [PATCH 6/6] egui_extras: syntax-highlighting of .toml (#3388) --- crates/egui_extras/src/syntax_highlighting.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/egui_extras/src/syntax_highlighting.rs b/crates/egui_extras/src/syntax_highlighting.rs index 1bb81d13f..775d7207b 100644 --- a/crates/egui_extras/src/syntax_highlighting.rs +++ b/crates/egui_extras/src/syntax_highlighting.rs @@ -508,6 +508,7 @@ impl Language { "c" | "h" | "hpp" | "cpp" | "c++" => Some(Self::cpp()), "py" | "python" => Some(Self::python()), "rs" | "rust" => Some(Self::rust()), + "toml" => Some(Self::toml()), _ => { None // unsupported language } @@ -655,4 +656,12 @@ impl Language { .collect(), } } + + fn toml() -> Self { + Self { + double_slash_comments: false, + hash_comments: true, + keywords: Default::default(), + } + } }