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/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/eframe/src/native/run.rs b/crates/eframe/src/native/run.rs index aaa42efea..4617b3f86 100644 --- a/crates/eframe/src/native/run.rs +++ b/crates/eframe/src/native/run.rs @@ -1560,7 +1560,7 @@ mod glow_integration { if win.read().window.as_ref().unwrap().read().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)); } } @@ -2479,7 +2479,7 @@ mod wgpu_integration { if window.read().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-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index 67dba4476..21e064127 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -340,7 +340,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); @@ -349,6 +349,7 @@ impl State { .events .push(egui::Event::CompositionUpdate(text.clone())); } + winit::event::Ime::Preedit(_, None) => {} }; EventResponse { diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 6f1c84325..ab1e956a4 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -734,10 +734,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. @@ -1104,6 +1100,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 e4b36314e..6e4d159df 100644 --- a/crates/egui/src/data/output.rs +++ b/crates/egui/src/data/output.rs @@ -93,6 +93,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")] @@ -101,7 +103,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)); } @@ -165,6 +169,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 1330fee04..669fd6efc 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -390,7 +390,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/memory.rs b/crates/egui/src/memory.rs index 575ffea03..de9280c25 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 crate::{ area, window, EventFilter, Id, IdMap, InputState, LayerId, Pos2, Rect, Style, ViewportId, }; @@ -19,6 +21,7 @@ use epaint::{emath::Rangef, vec2, Vec2}; #[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. @@ -692,21 +695,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; @@ -728,22 +735,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/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/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/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/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(), + } + } } 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()) } 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))] 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 { 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()); } });