diff --git a/CHANGELOG.md b/CHANGELOG.md index d236a6153..809cc413f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added ⭐ -* You can now check if a `TextEdit` lost keyboard focus with `response.lost_kb_focus`. -* Added `ui.text_edit_singleline` and `ui.text_edit_multiline`. +* `TextEdit` improvements: + * Much improved text editing, with better navigation and selection. + * Move focus between `TextEdit` widgets with tab and shift-tab. + * Undo edtis in a `TextEdit`. + * You can now check if a `TextEdit` lost keyboard focus with `response.lost_kb_focus`. + * Added `ui.text_edit_singleline` and `ui.text_edit_multiline`. ### Changed πŸ”§ * Pressing enter in a single-line `TextEdit` will now surrender keyboard focus for it. * You must now be explicit when creating a `TextEdit` if you want it to be singeline or multiline. * Improved automatic `Id` generation, making `Id` clashes less likely. +* Egui now requires modifier key state from the integration +* Added, renamed and removed some keys in the `Key` enum. ### Fixed πŸ› diff --git a/TODO.md b/TODO.md index b5226ce6a..3b380942b 100644 --- a/TODO.md +++ b/TODO.md @@ -4,8 +4,8 @@ TODO-list for the Egui project. If you looking for something to do, look here. ## Top priority -* Text input: text selection etc -* Refactor graphics layers and areas so one don't have to register LayerId:s. +* Egui-web copy-paste +* Egui-web fetch ## Other @@ -14,13 +14,6 @@ TODO-list for the Egui project. If you looking for something to do, look here. * [ ] Tooltip widget: Something that looks like this: (?) :that shows text on hover. * [ ] ui.info_button().on_hover_text("More info here"); * [ ] Allow adding multiple tooltips to the same widget, showing them all one after the other. - * [ ] Text input - * [x] Input - * [x] Text focus - * [x] Cursor movement - * [ ] Text selection - * [ ] Clipboard copy/paste - * [ ] Move focus with tab * [ ] Vertical slider * [/] Color picker * [x] linear rgb <-> sRGB @@ -32,26 +25,19 @@ TODO-list for the Egui project. If you looking for something to do, look here. * [ ] Additive blending aware color picker * [ ] Premultiplied alpha is a bit of a pain in the ass. Maybe rethink this a bit. * [ ] Hue wheel -* Containers - * [ ] Scroll areas - * [x] Vertical scrolling - * [x] Scroll-wheel input - * [x] Drag background to scroll - * [x] Kinetic scrolling - * [ ] Horizontal scrolling * Input * [x] Distinguish between clicks and drags * [x] Double-click * [x] Text + * [x] Modifier keys * [ ] Support all mouse buttons * [ ] Distinguish between touch input and mouse input - * [ ] Get modifier keys - * [ ] Keyboard shortcuts - * [ ] Copy, paste, undo, ... * Text * [/] Unicode * [x] Shared mutable expanding texture map - * [ ] Text editing of unicode + * [/] Text editing of unicode (needs more testing) + * [ ] Font with some more unicode characters + * [ ] Emoji support (great for things like β–ΆοΈβΈβΉβš οΈŽ) * [ ] Change text style/color and continue in same layout * Menu bar (File, Edit, etc) * [ ] Sub-menus @@ -90,26 +76,14 @@ TODO-list for the Egui project. If you looking for something to do, look here. * [ ] Ask Egui if an event requires repainting * [ ] Only repaint when mouse is over a Egui window (or is pressed and there is an active widget) -## Backends - -* [ ] Extract egui_app as egui_backend - -* egui_glium -* egui_web - * [ ] async HTTP requests -* [ ] egui_bitmap: slow reference rasterizer for tests - * Port https://github.com/emilk/imgui_software_renderer - * Less important: fast rasterizer for embedded πŸ€·β€β™€οΈ -* [ ] egui_terminal (think ncurses) - * [ ] replace `round_to_pixel` with `round_to_X` where user can select X to be e.g. width of a letter -* [ ] egui_svg: No idea what this would be for :) +## Integrations ### egui_web - * [x] Scroll input * [x] Change to resize cursor on hover * [x] Port most code to Rust * [x] Read url fragment and redirect to a subpage (e.g. different examples apps)] +* [ ] Copy/paste support * [ ] Async HTTP requests * [ ] Fix WebGL colors/blending (try EXT_sRGB) * [ ] Embeddability @@ -120,6 +94,17 @@ TODO-list for the Egui project. If you looking for something to do, look here. * Different Egui instances, same app * Allows very nice web integration +### Other + +* [ ] Extract egui::app as own library (egui_framework ?) +* [ ] egui_bitmap: slow reference rasterizer for tests + * Port https://github.com/emilk/imgui_software_renderer + * Less important: fast rasterizer for embedded πŸ€·β€β™€οΈ +* [ ] egui_terminal (think ncurses) + * [ ] replace `round_to_pixel` with `round_to_X` where user can select X to be e.g. width of a letter +* [ ] egui_svg: No idea what this would be for :) + + ## Modularity * [x] `trait Widget` (`Label`, `Slider`, `Checkbox`, ...) @@ -162,13 +147,24 @@ Ability to do a search for any widget. The search works even for collapsed regio * [x] Collapsing header region * [x] Tooltip * [x] Movable/resizable windows - * [x] Kinetic windows * [x] Add support for clicking hyperlinks +* [x] Text input + * [x] Input + * [x] Text focus + * [x] Cursor movement + * [x] Text selection + * [x] Clipboard copy/paste + * [x] Move focus with tab + * [x] Text edit undo * Containers * [x] Vertical slider * [x] Resize any side and corner on windows * [x] Fix autoshrink * [x] Automatic positioning of new windows + * [x] Vertical scroll areas + * [x] Scroll-wheel input + * [x] Drag background to scroll + * [x] Kinetic scrolling * Simple animations * Clip rects * [x] Separate Ui::clip_rect from Ui::rect diff --git a/egui/src/containers/window.rs b/egui/src/containers/window.rs index 24768017d..58efd4a83 100644 --- a/egui/src/containers/window.rs +++ b/egui/src/containers/window.rs @@ -592,7 +592,7 @@ fn paint_frame_interaction( struct TitleBar { title_label: Label, - title_galley: font::Galley, + title_galley: Galley, title_rect: Rect, rect: Rect, } diff --git a/egui/src/context.rs b/egui/src/context.rs index afa2055db..41bfc6ead 100644 --- a/egui/src/context.rs +++ b/egui/src/context.rs @@ -205,7 +205,7 @@ impl Context { } fn begin_frame_mut(&mut self, new_raw_input: RawInput) { - self.memory().begin_frame(&self.input); + self.memory().begin_frame(&self.input, &new_raw_input); self.input = std::mem::take(&mut self.input).begin_frame(new_raw_input); *self.available_rect.lock() = Some(self.input.screen_rect()); diff --git a/egui/src/demos/app.rs b/egui/src/demos/app.rs index ad810bad8..099e6eb61 100644 --- a/egui/src/demos/app.rs +++ b/egui/src/demos/app.rs @@ -1,4 +1,4 @@ -use crate::{app, demos, Context, History, Ui}; +use crate::{app, demos, util::History, Context, Ui}; use std::sync::Arc; // ---------------------------------------------------------------------------- diff --git a/egui/src/input.rs b/egui/src/input.rs index 64002a1ed..ff987310e 100644 --- a/egui/src/input.rs +++ b/egui/src/input.rs @@ -1,6 +1,6 @@ //! The input needed by Egui. -use crate::{math::*, History}; +use crate::{math::*, util::History}; /// If mouse moves more than this, it is no longer a click (but maybe a drag) const MAX_CLICK_DIST: f32 = 6.0; @@ -33,6 +33,9 @@ pub struct RawInput { /// Time in seconds. Relative to whatever. Used for animations. pub time: f64, + /// Which modifier keys are down at the start of the frame? + pub modifiers: Modifiers, + /// In-order events received this frame pub events: Vec, } @@ -47,6 +50,7 @@ impl RawInput { screen_size: self.screen_size, pixels_per_point: self.pixels_per_point, time: self.time, + modifiers: self.modifiers, events: std::mem::take(&mut self.events), } } @@ -80,6 +84,9 @@ pub struct InputState { /// Should be set to the expected time between frames when painting at vsync speeds. pub predicted_dt: f32, + /// Which modifier keys are down at the start of the frame? + pub modifiers: Modifiers, + /// In-order events received this frame pub events: Vec, } @@ -151,7 +158,7 @@ impl Default for MouseInput { } /// An input event. Only covers events used by Egui. -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum Event { Copy, Cut, @@ -161,33 +168,51 @@ pub enum Event { Key { key: Key, pressed: bool, + modifiers: Modifiers, }, } -/// Keyboard key name. Only covers keys used by Egui. +/// State of the modifier keys. These must be fed to Egui. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct Modifiers { + /// Either of the alt keys are down (option βŒ₯ on Mac) + pub alt: bool, + /// Either of the control keys are down + pub ctrl: bool, + /// Either of the shift keys are down + pub shift: bool, + /// The Mac ⌘ Command key. Should always be set to `false` on other platforms. + pub mac_cmd: bool, + /// On Mac, this should be set whenever one of the ⌘ Command keys are down (same as `mac_cmd`). + /// On Windows and Linux, set this to the same value as `ctrl`. + /// This is so that Egui can, for instance, select all text by checking for `command + A` + /// and it will work on both Mac and Windows. + pub command: bool, +} + +/// Keyboard key name. Only covers keys used by Egui (mostly for text editing). #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum Key { - Alt, + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUp, Backspace, - Control, Delete, - Down, End, + Enter, Escape, Home, Insert, - Left, - /// Windows key or Mac Command key - Logo, PageDown, PageUp, - /// Enter/Return key - Enter, - Right, - Shift, - // Space, Tab, - Up, + + A, // Used for cmd+A (select All) + K, // Used for ctrl+K (delete text after cursor) + U, // Used for ctrl+U (delete text before cursor) + W, // Used for ctrl+W (delete previous word) + Z, // Used for cmd+Z (undo) } impl InputState { @@ -202,7 +227,8 @@ impl InputState { pixels_per_point: new.pixels_per_point.or(self.pixels_per_point), time: new.time, unstable_dt, - predicted_dt: 1.0 / 60.0, // TODO: remove this hack + predicted_dt: 1.0 / 60.0, // TODO: remove this hack + modifiers: new.modifiers, events: new.events.clone(), // TODO: remove clone() and use raw.events raw: new, } @@ -227,7 +253,8 @@ impl InputState { event, Event::Key { key, - pressed: true + pressed: true, + .. } if *key == desired_key ) }) @@ -240,7 +267,8 @@ impl InputState { event, Event::Key { key, - pressed: false + pressed: false, + .. } if *key == desired_key ) }) @@ -343,6 +371,7 @@ impl RawInput { screen_size, pixels_per_point, time, + modifiers, events, } = self; @@ -357,6 +386,7 @@ impl RawInput { "Also called HDPI factor.\nNumber of physical pixels per each logical pixel.", ); ui.label(format!("time: {:.3} s", time)); + ui.label(format!("modifiers: {:#?}", modifiers)); ui.label(format!("events: {:?}", events)) .on_hover_text("key presses etc"); } @@ -373,6 +403,7 @@ impl InputState { time, unstable_dt, predicted_dt, + modifiers, events, } = self; @@ -397,6 +428,7 @@ impl InputState { 1e3 * unstable_dt )); ui.label(format!("expected dt: {:.1} ms", 1e3 * predicted_dt)); + ui.label(format!("modifiers: {:#?}", modifiers)); ui.label(format!("events: {:?}", events)) .on_hover_text("key presses etc"); } diff --git a/egui/src/lib.rs b/egui/src/lib.rs index 063731387..c2292ce8b 100644 --- a/egui/src/lib.rs +++ b/egui/src/lib.rs @@ -60,11 +60,9 @@ pub mod align; mod animation_manager; pub mod app; -pub(crate) mod cache; pub mod containers; mod context; pub mod demos; -mod history; mod id; mod input; mod introspection; @@ -73,12 +71,12 @@ mod layout; pub mod math; mod memory; pub mod menu; -pub mod mutex; pub mod paint; mod painter; mod style; mod types; mod ui; +pub mod util; pub mod widgets; pub use { @@ -86,7 +84,6 @@ pub use { containers::*, context::Context, demos::DemoApp, - history::History, id::Id, input::*, layers::*, @@ -101,6 +98,7 @@ pub use { style::Style, types::*, ui::Ui, + util::mutex, widgets::*, }; diff --git a/egui/src/memory.rs b/egui/src/memory.rs index ac9122796..993848d59 100644 --- a/egui/src/memory.rs +++ b/egui/src/memory.rs @@ -1,11 +1,10 @@ use std::collections::{HashMap, HashSet}; use crate::{ - area, - cache::Cache, - collapsing_header, menu, + area, collapsing_header, menu, paint::color::{Hsva, Srgba}, resize, scroll_area, + util::Cache, widgets::text_edit, window, Id, LayerId, Pos2, Rect, }; @@ -84,6 +83,18 @@ pub(crate) struct Interaction { /// What had keyboard focus previous frame? pub kb_focus_id_previous_frame: Option, + /// If set, the next widget that is interested in kb_focus will automatically get it. + /// Probably because the user pressed Tab. + pub kb_focus_give_to_next: bool, + + /// The last widget interested in kb focus. + pub kb_focus_last_interested: Option, + + /// Set at the beginning of the frame, set to `false` when "used". + pressed_tab: bool, + /// Set at the beginning of the frame, set to `false` when "used". + pressed_shift_tab: bool, + /// HACK: windows have low priority on dragging. /// This is so that if you drag a slider in a window, /// the slider will steal the drag away from the window. @@ -105,7 +116,11 @@ impl Interaction { self.click_id.is_some() || self.drag_id.is_some() } - fn begin_frame(&mut self, prev_input: &crate::input::InputState) { + fn begin_frame( + &mut self, + prev_input: &crate::input::InputState, + new_input: &crate::input::RawInput, + ) { self.kb_focus_id_previous_frame = self.kb_focus_id; self.click_interest = false; self.drag_interest = false; @@ -119,13 +134,34 @@ impl Interaction { self.click_id = None; self.drag_id = None; } + + self.pressed_tab = false; + self.pressed_shift_tab = false; + for event in &new_input.events { + if let crate::input::Event::Key { + key: crate::input::Key::Tab, + pressed: true, + modifiers, + } = event + { + if modifiers.shift { + self.pressed_shift_tab = true; + } else { + self.pressed_tab = true; + } + } + } } } impl Memory { - pub(crate) fn begin_frame(&mut self, prev_input: &crate::input::InputState) { + pub(crate) fn begin_frame( + &mut self, + prev_input: &crate::input::InputState, + new_input: &crate::input::RawInput, + ) { self.used_ids.clear(); - self.interaction.begin_frame(prev_input); + self.interaction.begin_frame(prev_input, new_input); if !prev_input.mouse.down { self.window_interaction = None; @@ -170,6 +206,26 @@ impl Memory { } } + /// Register this widget as being interested in getting keyboard focus. + /// This will allow the user to select it with tab and shift-tab. + pub fn interested_in_kb_focus(&mut self, id: Id) { + if self.interaction.kb_focus_give_to_next { + self.interaction.kb_focus_id = Some(id); + self.interaction.kb_focus_give_to_next = false; + } else if self.has_kb_focus(id) { + if self.interaction.pressed_tab { + self.interaction.kb_focus_id = None; + self.interaction.kb_focus_give_to_next = true; + self.interaction.pressed_tab = false; + } else if self.interaction.pressed_shift_tab { + self.interaction.kb_focus_id = self.interaction.kb_focus_last_interested; + self.interaction.pressed_shift_tab = false; + } + } + + self.interaction.kb_focus_last_interested = Some(id); + } + /// Stop editing of active `TextEdit` (if any). pub fn stop_text_input(&mut self) { self.interaction.kb_focus_id = None; diff --git a/egui/src/paint/command.rs b/egui/src/paint/command.rs index 7696ad5e6..561549ec8 100644 --- a/egui/src/paint/command.rs +++ b/egui/src/paint/command.rs @@ -1,5 +1,5 @@ use { - super::{font::Galley, fonts::TextStyle, Fonts, Srgba, Triangles}, + super::{fonts::TextStyle, Fonts, Galley, Srgba, Triangles}, crate::{ align::{anchor_rect, Align}, math::{Pos2, Rect}, diff --git a/egui/src/paint/font.rs b/egui/src/paint/font.rs index 18662d87a..a5509499d 100644 --- a/egui/src/paint/font.rs +++ b/egui/src/paint/font.rs @@ -9,145 +9,11 @@ use { use crate::{ math::{vec2, Vec2}, mutex::Mutex, + paint::{Galley, Row}, }; use super::texture_atlas::TextureAtlas; -#[derive(Clone, Copy, Debug, Default)] -pub struct GalleyCursor { - /// character count in whole galley - pub char_idx: usize, - /// line number - pub line: usize, - /// character count on this line - pub column: usize, -} - -/// A collection of text locked into place. -#[derive(Clone, Debug, Default)] -pub struct Galley { - /// The full text - pub text: String, - - /// Lines of text, from top to bottom. - /// The number of chars in all lines sum up to text.chars().count() - pub lines: Vec, - - // Optimization: calculate once and reuse. - pub size: Vec2, -} - -/// A typeset piece of text on a single line. -#[derive(Clone, Debug)] -pub struct Line { - /// The start of each character, probably starting at zero. - /// The last element is the end of the last character. - /// x_offsets.len() == text.chars().count() + 1 - /// This is never empty. - /// Unit: points. - pub x_offsets: Vec, - - /// Top of the line, offset within the Galley. - /// Unit: points. - pub y_min: f32, - - /// Bottom of the line, offset within the Galley. - /// Unit: points. - pub y_max: f32, - - /// If true, the last char on this line is '\n' - pub ends_with_newline: bool, -} - -impl Galley { - pub fn sanity_check(&self) { - let mut char_count = 0; - for line in &self.lines { - line.sanity_check(); - char_count += line.char_count(); - } - assert_eq!(char_count, self.text.chars().count()); - } - - /// If given a char index after the first line, the end of the last character is returned instead. - /// Returns a Vec2 rather than a Pos2 as this is an offset into the galley. *shrug* - pub fn char_start_pos(&self, char_idx: usize) -> Vec2 { - let mut char_count = 0; - for line in &self.lines { - let line_char_count = line.char_count(); - if char_count <= char_idx && char_idx < char_count + line_char_count { - let line_char_offset = char_idx - char_count; - return vec2(line.x_offsets[line_char_offset], line.y_min); - } - char_count += line_char_count; - } - - if let Some(last) = self.lines.last() { - vec2(last.max_x(), last.y_min) - } else { - // Empty galley - vec2(0.0, 0.0) - } - } - - /// Character offset at the given position within the galley - pub fn char_at(&self, pos: Vec2) -> GalleyCursor { - let mut best_y_dist = f32::INFINITY; - let mut cursor = GalleyCursor::default(); - - let mut char_count = 0; - for (line_nr, line) in self.lines.iter().enumerate() { - let y_dist = (line.y_min - pos.y).abs().min((line.y_max - pos.y).abs()); - if y_dist < best_y_dist { - best_y_dist = y_dist; - let mut column = line.char_at(pos.x); - if column == line.char_count() && line.ends_with_newline && column > 0 { - // handle the case where line ends with a \n and we click after it. - // We should return the position BEFORE the \n! - column -= 1; - } - cursor = GalleyCursor { - char_idx: char_count + column, - line: line_nr, - column, - } - } - char_count += line.char_count(); - } - cursor - } -} - -impl Line { - pub fn sanity_check(&self) { - assert!(!self.x_offsets.is_empty()); - } - - pub fn char_count(&self) -> usize { - assert!(!self.x_offsets.is_empty()); - self.x_offsets.len() - 1 - } - - pub fn min_x(&self) -> f32 { - *self.x_offsets.first().unwrap() - } - - pub fn max_x(&self) -> f32 { - *self.x_offsets.last().unwrap() - } - - /// Closest char at the desired x coordinate. returns something in the range `[0, char_count()]` - pub fn char_at(&self, desired_x: f32) -> usize { - for (i, char_x_bounds) in self.x_offsets.windows(2).enumerate() { - let char_center_x = 0.5 * (char_x_bounds[0] + char_x_bounds[1]); - if desired_x < char_center_x { - return i; - } - } - self.char_count() - } -} - // ---------------------------------------------------------------------------- // const REPLACEMENT_CHAR: char = '\u{25A1}'; // β–‘ white square Replaces a missing or unsupported Unicode character. @@ -244,12 +110,8 @@ impl Font { (point * self.pixels_per_point).round() / self.pixels_per_point } - /// Height of one line of text. In points - /// TODO: rename height ? - pub fn line_spacing(&self) -> f32 { - self.scale_in_pixels / self.pixels_per_point - } - pub fn height(&self) -> f32 { + /// Height of one row of text. In points + pub fn row_height(&self) -> f32 { self.scale_in_pixels / self.pixels_per_point } @@ -257,12 +119,8 @@ impl Font { self.glyph_infos.read().get(&c).and_then(|gi| gi.uv_rect) } + /// `\n` will (intentionally) show up as '?' (`REPLACEMENT_CHAR`) fn glyph_info(&self, c: char) -> GlyphInfo { - if c == '\n' { - // Hack: else we show '\n' as '?' (REPLACEMENT_CHAR) - return self.glyph_info(' '); - } - { if let Some(glyph_info) = self.glyph_infos.read().get(&c) { return *glyph_info; @@ -283,22 +141,22 @@ impl Font { glyph_info } - /// Typeset the given text onto one line. - /// Assumes there are no \n in the text. - /// Always returns exactly one fragment. + /// Typeset the given text onto one row. + /// Any `\n` will show up as `REPLACEMENT_CHAR` ('?'). + /// Always returns exactly one `Row` in the `Galley`. pub fn layout_single_line(&self, text: String) -> Galley { - let x_offsets = self.layout_single_line_fragment(&text); - let line = Line { + let x_offsets = self.layout_single_row_fragment(&text); + let row = Row { x_offsets, y_min: 0.0, - y_max: self.height(), + y_max: self.row_height(), ends_with_newline: false, }; - let width = line.max_x(); - let size = vec2(width, self.height()); + let width = row.max_x(); + let size = vec2(width, self.row_height()); let galley = Galley { text, - lines: vec![line], + rows: vec![row], size, }; galley.sanity_check(); @@ -306,61 +164,61 @@ impl Font { } pub fn layout_multiline(&self, text: String, max_width_in_points: f32) -> Galley { - let line_spacing = self.line_spacing(); + let row_height = self.row_height(); let mut cursor_y = 0.0; - let mut lines = Vec::new(); + let mut rows = Vec::new(); let mut paragraph_start = 0; while paragraph_start < text.len() { let next_newline = text[paragraph_start..].find('\n'); let paragraph_end = next_newline - .map(|newline| paragraph_start + newline + 1) + .map(|newline| paragraph_start + newline) .unwrap_or_else(|| text.len()); - assert!(paragraph_start < paragraph_end); + assert!(paragraph_start <= paragraph_end); let paragraph_text = &text[paragraph_start..paragraph_end]; - let mut paragraph_lines = + let mut paragraph_rows = self.layout_paragraph_max_width(paragraph_text, max_width_in_points); - assert!(!paragraph_lines.is_empty()); + assert!(!paragraph_rows.is_empty()); + paragraph_rows.last_mut().unwrap().ends_with_newline = next_newline.is_some(); - for line in &mut paragraph_lines { - line.y_min += cursor_y; - line.y_max += cursor_y; + for row in &mut paragraph_rows { + row.y_min += cursor_y; + row.y_max += cursor_y; } - cursor_y = paragraph_lines.last().unwrap().y_max; - cursor_y += line_spacing * 0.4; // extra spacing between paragraphs. less hacky + cursor_y = paragraph_rows.last().unwrap().y_max; + cursor_y += row_height * 0.4; // Extra spacing between paragraphs. TODO: less hacky - lines.append(&mut paragraph_lines); + rows.append(&mut paragraph_rows); - paragraph_start = paragraph_end; + paragraph_start = paragraph_end + 1; } if text.is_empty() || text.ends_with('\n') { - // Add an empty last line for correct visuals etc: - lines.push(Line { + rows.push(Row { x_offsets: vec![0.0], y_min: cursor_y, - y_max: cursor_y + line_spacing, - ends_with_newline: text.ends_with('\n'), + y_max: cursor_y + row_height, + ends_with_newline: false, }); } - let mut widest_line = 0.0; - for line in &lines { - widest_line = line.max_x().max(widest_line); + let mut widest_row = 0.0; + for row in &rows { + widest_row = row.max_x().max(widest_row); } - let size = vec2(widest_line, lines.last().unwrap().y_max); + let size = vec2(widest_row, rows.last().unwrap().y_max); - let galley = Galley { text, lines, size }; + let galley = Galley { text, rows, size }; galley.sanity_check(); galley } - /// Typeset the given text onto one line. - /// Assumes there are no \n in the text. + /// Typeset the given text onto one row. + /// Assumes there are no `\n` in the text. /// Return `x_offsets`, one longer than the number of characters in the text. - fn layout_single_line_fragment(&self, text: &str) -> Vec { + fn layout_single_row_fragment(&self, text: &str) -> Vec { let scale_in_pixels = Scale::uniform(self.scale_in_pixels); let mut x_offsets = Vec::with_capacity(text.chars().count() + 1); @@ -389,59 +247,69 @@ impl Font { } /// A paragraph is text with no line break character in it. - /// The text will be linebreaked by the given `max_width_in_points`. - pub fn layout_paragraph_max_width(&self, text: &str, max_width_in_points: f32) -> Vec { - let full_x_offsets = self.layout_single_line_fragment(text); + /// The text will be wrapped by the given `max_width_in_points`. + fn layout_paragraph_max_width(&self, text: &str, max_width_in_points: f32) -> Vec { + if text == "" { + return vec![Row { + x_offsets: vec![0.0], + y_min: 0.0, + y_max: self.row_height(), + ends_with_newline: false, + }]; + } - let mut line_start_x = full_x_offsets[0]; + let full_x_offsets = self.layout_single_row_fragment(text); + + let mut row_start_x = full_x_offsets[0]; { #![allow(clippy::float_cmp)] - assert_eq!(line_start_x, 0.0); + assert_eq!(row_start_x, 0.0); } let mut cursor_y = 0.0; - let mut line_start_idx = 0; + let mut row_start_idx = 0; - // start index of the last space. A candidate for a new line. + // start index of the last space. A candidate for a new row. let mut last_space = None; - let mut out_lines = vec![]; + let mut out_rows = vec![]; for (i, (x, chr)) in full_x_offsets.iter().skip(1).zip(text.chars()).enumerate() { - let line_width = x - line_start_x; + debug_assert!(chr != '\n'); + let potential_row_width = x - row_start_x; - if line_width > max_width_in_points { + if potential_row_width > max_width_in_points { if let Some(last_space_idx) = last_space { let include_trailing_space = true; - let line = if include_trailing_space { - Line { - x_offsets: full_x_offsets[line_start_idx..=last_space_idx + 1] + let row = if include_trailing_space { + Row { + x_offsets: full_x_offsets[row_start_idx..=last_space_idx + 1] .iter() - .map(|x| x - line_start_x) + .map(|x| x - row_start_x) .collect(), y_min: cursor_y, - y_max: cursor_y + self.height(), - ends_with_newline: false, // we'll fix this later + y_max: cursor_y + self.row_height(), + ends_with_newline: false, } } else { - Line { - x_offsets: full_x_offsets[line_start_idx..=last_space_idx] + Row { + x_offsets: full_x_offsets[row_start_idx..=last_space_idx] .iter() - .map(|x| x - line_start_x) + .map(|x| x - row_start_x) .collect(), y_min: cursor_y, - y_max: cursor_y + self.height(), - ends_with_newline: false, // we'll fix this later + y_max: cursor_y + self.row_height(), + ends_with_newline: false, } }; - line.sanity_check(); - out_lines.push(line); + row.sanity_check(); + out_rows.push(row); - line_start_idx = last_space_idx + 1; - line_start_x = full_x_offsets[line_start_idx]; + row_start_idx = last_space_idx + 1; + row_start_x = full_x_offsets[row_start_idx]; last_space = None; - cursor_y += self.line_spacing(); + cursor_y += self.row_height(); cursor_y = self.round_to_pixel(cursor_y); } } @@ -452,25 +320,21 @@ impl Font { } } - if line_start_idx + 1 < full_x_offsets.len() { - let line = Line { - x_offsets: full_x_offsets[line_start_idx..] + if row_start_idx + 1 < full_x_offsets.len() { + let row = Row { + x_offsets: full_x_offsets[row_start_idx..] .iter() - .map(|x| x - line_start_x) + .map(|x| x - row_start_x) .collect(), y_min: cursor_y, - y_max: cursor_y + self.height(), - ends_with_newline: false, // we'll fix this later + y_max: cursor_y + self.row_height(), + ends_with_newline: false, }; - line.sanity_check(); - out_lines.push(line); - } - - if text.ends_with('\n') { - out_lines.last_mut().unwrap().ends_with_newline = true; + row.sanity_check(); + out_rows.push(row); } - out_lines + out_rows } } diff --git a/egui/src/paint/galley.rs b/egui/src/paint/galley.rs new file mode 100644 index 000000000..02202ef96 --- /dev/null +++ b/egui/src/paint/galley.rs @@ -0,0 +1,879 @@ +//! This is going to get complicated. +//! +//! To avoid confusion, we never use the word "line". +//! The `\n` character demarcates the split of text into "paragraphs". +//! Each paragraph is wrapped at some width onto one or more "rows". +//! +//! If this cursors sits right at the border of a wrapped row break (NOT paragraph break) +//! do we prefer the next row? +//! For instance, consider this single paragraph, word wrapped: +//! ``` text +//! Hello_ +//! world! +//! ``` +//! +//! The offset `6` is both the end of the first row +//! and the start of the second row. +//! The `prefer_next_row` selects which. + +use crate::math::{pos2, NumExt, Rect, Vec2}; + +/// Character cursor +#[derive(Clone, Copy, Debug, Default)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct CCursor { + /// Character offset (NOT byte offset!). + pub index: usize, + + /// If this cursors sits right at the border of a wrapped row break (NOT paragraph break) + /// do we prefer the next row? + /// This is *almost* always what you want, *except* for when + /// explicitly clicking the end of a row or pressing the end key. + pub prefer_next_row: bool, +} + +impl CCursor { + pub fn new(index: usize) -> Self { + Self { + index, + prefer_next_row: false, + } + } +} + +/// Two `CCursor`s are considered equal if they refer to the same character boundary, +/// even if one prefers the start of the next row. +impl PartialEq for CCursor { + fn eq(&self, other: &CCursor) -> bool { + self.index == other.index + } +} + +impl std::ops::Add for CCursor { + type Output = CCursor; + fn add(self, rhs: usize) -> Self::Output { + CCursor { + index: self.index.saturating_add(rhs), + prefer_next_row: self.prefer_next_row, + } + } +} + +impl std::ops::Sub for CCursor { + type Output = CCursor; + fn sub(self, rhs: usize) -> Self::Output { + CCursor { + index: self.index.saturating_sub(rhs), + prefer_next_row: self.prefer_next_row, + } + } +} + +/// Row Cursor +#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct RCursor { + /// 0 is first row, and so on. + /// Note that a single paragraph can span multiple rows. + /// (a paragraph is text separated by `\n`). + pub row: usize, + + /// Character based (NOT bytes). + /// It is fine if this points to something beyond the end of the current row. + /// When moving up/down it may again be within the next row. + pub column: usize, +} + +/// Paragraph Cursor +#[derive(Clone, Copy, Debug, Default)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct PCursor { + /// 0 is first paragraph, and so on. + /// Note that a single paragraph can span multiple rows. + /// (a paragraph is text separated by `\n`). + pub paragraph: usize, + + /// Character based (NOT bytes). + /// It is fine if this points to something beyond the end of the current paragraph. + /// When moving up/down it may again be within the next paragraph. + pub offset: usize, + + /// If this cursors sits right at the border of a wrapped row break (NOT paragraph break) + /// do we prefer the next row? + /// This is *almost* always what you want, *except* for when + /// explicitly clicking the end of a row or pressing the end key. + pub prefer_next_row: bool, +} + +/// Two `PCursor`s are considered equal if they refer to the same character boundary, +/// even if one prefers the start of the next row. +impl PartialEq for PCursor { + fn eq(&self, other: &PCursor) -> bool { + self.paragraph == other.paragraph && self.offset == other.offset + } +} + +/// All different types of cursors together. +/// They all point to the same place, but in their own different ways. +/// pcursor/rcursor can also point to after the end of the paragraph/row. +/// Does not implement `PartialEq` because you must think which cursor should be equivalent. +#[derive(Clone, Copy, Debug, Default)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Cursor { + pub ccursor: CCursor, + pub rcursor: RCursor, + pub pcursor: PCursor, +} + +/// A collection of text locked into place. +#[derive(Clone, Debug, Default)] +pub struct Galley { + /// The full text, including any an all `\n`. + pub text: String, + + /// Rows of text, from top to bottom. + /// The number of chars in all rows sum up to text.chars().count(). + /// Note that each paragraph (pieces of text separated with `\n`) + /// can be split up into multiple rows. + pub rows: Vec, + + // Optimization: calculated once and reused. + pub size: Vec2, +} + +/// A typeset piece of text on a single row. +#[derive(Clone, Debug)] +pub struct Row { + /// The start of each character, probably starting at zero. + /// The last element is the end of the last character. + /// This is never empty. + /// Unit: points. + /// + /// `x_offsets.len() + (ends_with_newline as usize) == text.chars().count() + 1` + pub x_offsets: Vec, + + /// Top of the row, offset within the Galley. + /// Unit: points. + pub y_min: f32, + + /// Bottom of the row, offset within the Galley. + /// Unit: points. + pub y_max: f32, + + /// If true, this `Row` came from a paragraph ending with a `\n`. + /// The `\n` itself is omitted from `x_offsets`. + /// A `\n` in the input text always creates a new `Row` below it, + /// so that text that ends with `\n` has an empty `Row` last. + /// This also implies that the last `Row` in a `Galley` always has `ends_with_newline == false`. + pub ends_with_newline: bool, +} + +impl Row { + pub fn sanity_check(&self) { + assert!(!self.x_offsets.is_empty()); + } + + /// Excludes the implicit `\n` after the `Row`, if any. + pub fn char_count_excluding_newline(&self) -> usize { + assert!(!self.x_offsets.is_empty()); + self.x_offsets.len() - 1 + } + + /// Includes the implicit `\n` after the `Row`, if any. + pub fn char_count_including_newline(&self) -> usize { + self.char_count_excluding_newline() + (self.ends_with_newline as usize) + } + + pub fn min_x(&self) -> f32 { + *self.x_offsets.first().unwrap() + } + + pub fn max_x(&self) -> f32 { + *self.x_offsets.last().unwrap() + } + + pub fn height(&self) -> f32 { + self.y_max - self.y_min + } + + /// Closest char at the desired x coordinate. + /// Returns something in the range `[0, char_count_excluding_newline()]`. + pub fn char_at(&self, desired_x: f32) -> usize { + for (i, char_x_bounds) in self.x_offsets.windows(2).enumerate() { + let char_center_x = 0.5 * (char_x_bounds[0] + char_x_bounds[1]); + if desired_x < char_center_x { + return i; + } + } + self.char_count_excluding_newline() + } + + pub fn x_offset(&self, column: usize) -> f32 { + self.x_offsets[column.min(self.x_offsets.len() - 1)] + } +} + +impl Galley { + pub fn sanity_check(&self) { + let mut char_count = 0; + for row in &self.rows { + row.sanity_check(); + char_count += row.char_count_including_newline(); + } + assert_eq!(char_count, self.text.chars().count()); + if let Some(last_row) = self.rows.last() { + debug_assert!( + !last_row.ends_with_newline, + "If the text ends with '\\n', there would be an empty row last.\n\ + Galley: {:#?}", + self + ); + } + } +} + +/// ## Physical positions +impl Galley { + fn end_pos(&self) -> Rect { + if let Some(row) = self.rows.last() { + let x = row.max_x(); + Rect::from_min_max(pos2(x, row.y_min), pos2(x, row.y_max)) + } else { + // Empty galley + Rect::from_min_max(pos2(0.0, 0.0), pos2(0.0, 0.0)) + } + } + + /// Returns a 0-width Rect. + pub fn pos_from_pcursor(&self, pcursor: PCursor) -> Rect { + let mut it = PCursor::default(); + + for row in &self.rows { + if it.paragraph == pcursor.paragraph { + // Right paragraph, but is it the right row in the paragraph? + + if it.offset <= pcursor.offset + && (pcursor.offset <= it.offset + row.char_count_excluding_newline() + || row.ends_with_newline) + { + let column = pcursor.offset - it.offset; + + let select_next_row_instead = pcursor.prefer_next_row + && !row.ends_with_newline + && column >= row.char_count_excluding_newline(); + if !select_next_row_instead { + let x = row.x_offset(column); + return Rect::from_min_max(pos2(x, row.y_min), pos2(x, row.y_max)); + } + } + } + + if row.ends_with_newline { + it.paragraph += 1; + it.offset = 0; + } else { + it.offset += row.char_count_including_newline(); + } + } + + self.end_pos() + } + + /// Returns a 0-width Rect. + pub fn pos_from_cursor(&self, cursor: &Cursor) -> Rect { + self.pos_from_pcursor(cursor.pcursor) // The one TextEdit stores + } + + /// Cursor at the given position within the galley + pub fn cursor_from_pos(&self, pos: Vec2) -> Cursor { + let mut best_y_dist = f32::INFINITY; + let mut cursor = Cursor::default(); + + let mut ccursor_index = 0; + let mut pcursor_it = PCursor::default(); + + for (row_nr, row) in self.rows.iter().enumerate() { + let y_dist = (row.y_min - pos.y).abs().min((row.y_max - pos.y).abs()); + if y_dist < best_y_dist { + best_y_dist = y_dist; + let column = row.char_at(pos.x); + let prefer_next_row = column < row.char_count_excluding_newline(); + cursor = Cursor { + ccursor: CCursor { + index: ccursor_index + column, + prefer_next_row, + }, + rcursor: RCursor { + row: row_nr, + column, + }, + pcursor: PCursor { + paragraph: pcursor_it.paragraph, + offset: pcursor_it.offset + column, + prefer_next_row, + }, + } + } + ccursor_index += row.char_count_including_newline(); + if row.ends_with_newline { + pcursor_it.paragraph += 1; + pcursor_it.offset = 0; + } else { + pcursor_it.offset += row.char_count_including_newline(); + } + } + cursor + } +} + +/// ## Cursor positions +impl Galley { + /// Cursor to one-past last character. + pub fn end(&self) -> Cursor { + if self.rows.is_empty() { + return Default::default(); + } + let mut ccursor = CCursor { + index: 0, + prefer_next_row: true, + }; + let mut pcursor = PCursor { + paragraph: 0, + offset: 0, + prefer_next_row: true, + }; + for row in &self.rows { + let row_char_count = row.char_count_including_newline(); + ccursor.index += row_char_count; + if row.ends_with_newline { + pcursor.paragraph += 1; + pcursor.offset = 0; + } else { + pcursor.offset += row_char_count; + } + } + Cursor { + ccursor, + rcursor: self.end_rcursor(), + pcursor, + } + } + + pub fn end_rcursor(&self) -> RCursor { + if let Some(last_row) = self.rows.last() { + debug_assert!(!last_row.ends_with_newline); + RCursor { + row: self.rows.len() - 1, + column: last_row.char_count_excluding_newline(), + } + } else { + Default::default() + } + } +} + +/// ## Cursor conversions +impl Galley { + // The returned cursor is clamped. + pub fn from_ccursor(&self, ccursor: CCursor) -> Cursor { + let prefer_next_row = ccursor.prefer_next_row; + let mut ccursor_it = CCursor { + index: 0, + prefer_next_row, + }; + let mut pcursor_it = PCursor { + paragraph: 0, + offset: 0, + prefer_next_row, + }; + + for (row_nr, row) in self.rows.iter().enumerate() { + let row_char_count = row.char_count_excluding_newline(); + + if ccursor_it.index <= ccursor.index + && ccursor.index <= ccursor_it.index + row_char_count + { + let column = ccursor.index - ccursor_it.index; + + let select_next_row_instead = prefer_next_row + && !row.ends_with_newline + && column >= row.char_count_excluding_newline(); + if !select_next_row_instead { + pcursor_it.offset += column; + return Cursor { + ccursor, + rcursor: RCursor { + row: row_nr, + column, + }, + pcursor: pcursor_it, + }; + } + } + ccursor_it.index += row.char_count_including_newline(); + if row.ends_with_newline { + pcursor_it.paragraph += 1; + pcursor_it.offset = 0; + } else { + pcursor_it.offset += row.char_count_including_newline(); + } + } + debug_assert_eq!(ccursor_it, self.end().ccursor); + Cursor { + ccursor: ccursor_it, // clamp + rcursor: self.end_rcursor(), + pcursor: pcursor_it, + } + } + + pub fn from_rcursor(&self, rcursor: RCursor) -> Cursor { + if rcursor.row >= self.rows.len() { + return self.end(); + } + + let prefer_next_row = + rcursor.column < self.rows[rcursor.row].char_count_excluding_newline(); + let mut ccursor_it = CCursor { + index: 0, + prefer_next_row, + }; + let mut pcursor_it = PCursor { + paragraph: 0, + offset: 0, + prefer_next_row, + }; + + for (row_nr, row) in self.rows.iter().enumerate() { + if row_nr == rcursor.row { + ccursor_it.index += rcursor.column.at_most(row.char_count_excluding_newline()); + + if row.ends_with_newline { + // Allow offset to go beyond the end of the paragraph + pcursor_it.offset += rcursor.column; + } else { + pcursor_it.offset += rcursor.column.at_most(row.char_count_excluding_newline()); + } + return Cursor { + ccursor: ccursor_it, + rcursor, + pcursor: pcursor_it, + }; + } + ccursor_it.index += row.char_count_including_newline(); + if row.ends_with_newline { + pcursor_it.paragraph += 1; + pcursor_it.offset = 0; + } else { + pcursor_it.offset += row.char_count_including_newline(); + } + } + Cursor { + ccursor: ccursor_it, + rcursor: self.end_rcursor(), + pcursor: pcursor_it, + } + } + + // TODO: return identical cursor, or clamp? + pub fn from_pcursor(&self, pcursor: PCursor) -> Cursor { + let prefer_next_row = pcursor.prefer_next_row; + let mut ccursor_it = CCursor { + index: 0, + prefer_next_row, + }; + let mut pcursor_it = PCursor { + paragraph: 0, + offset: 0, + prefer_next_row, + }; + + for (row_nr, row) in self.rows.iter().enumerate() { + if pcursor_it.paragraph == pcursor.paragraph { + // Right paragraph, but is it the right row in the paragraph? + + if pcursor_it.offset <= pcursor.offset + && (pcursor.offset <= pcursor_it.offset + row.char_count_excluding_newline() + || row.ends_with_newline) + { + let column = pcursor.offset - pcursor_it.offset; + + let select_next_row_instead = pcursor.prefer_next_row + && !row.ends_with_newline + && column >= row.char_count_excluding_newline(); + + if !select_next_row_instead { + ccursor_it.index += column.at_most(row.char_count_excluding_newline()); + + return Cursor { + ccursor: ccursor_it, + rcursor: RCursor { + row: row_nr, + column, + }, + pcursor, + }; + } + } + } + + ccursor_it.index += row.char_count_including_newline(); + if row.ends_with_newline { + pcursor_it.paragraph += 1; + pcursor_it.offset = 0; + } else { + pcursor_it.offset += row.char_count_including_newline(); + } + } + Cursor { + ccursor: ccursor_it, + rcursor: self.end_rcursor(), + pcursor, + } + } +} + +/// ## Cursor positions +impl Galley { + pub fn cursor_left_one_character(&self, cursor: &Cursor) -> Cursor { + if cursor.ccursor.index == 0 { + Default::default() + } else { + let ccursor = CCursor { + index: cursor.ccursor.index, + prefer_next_row: true, // default to this when navigating. It is more often useful to put cursor at the begging of a row than at the end. + }; + self.from_ccursor(ccursor - 1) + } + } + + pub fn cursor_right_one_character(&self, cursor: &Cursor) -> Cursor { + let ccursor = CCursor { + index: cursor.ccursor.index, + prefer_next_row: true, // default to this when navigating. It is more often useful to put cursor at the begging of a row than at the end. + }; + self.from_ccursor(ccursor + 1) + } + + pub fn cursor_up_one_row(&self, cursor: &Cursor) -> Cursor { + if cursor.rcursor.row == 0 { + Cursor::default() + } else { + let new_row = cursor.rcursor.row - 1; + + let cursor_is_beyond_end_of_current_row = cursor.rcursor.column + >= self.rows[cursor.rcursor.row].char_count_excluding_newline(); + + let new_rcursor = if cursor_is_beyond_end_of_current_row { + // keep same column + RCursor { + row: new_row, + column: cursor.rcursor.column, + } + } else { + // keep same X coord + let x = self.pos_from_cursor(cursor).center().x; + let column = if x > self.rows[new_row].max_x() { + // beyond the end of this row - keep same colum + cursor.rcursor.column + } else { + self.rows[new_row].char_at(x) + }; + RCursor { + row: new_row, + column, + } + }; + self.from_rcursor(new_rcursor) + } + } + + pub fn cursor_down_one_row(&self, cursor: &Cursor) -> Cursor { + if cursor.rcursor.row + 1 < self.rows.len() { + let new_row = cursor.rcursor.row + 1; + + let cursor_is_beyond_end_of_current_row = cursor.rcursor.column + >= self.rows[cursor.rcursor.row].char_count_excluding_newline(); + + let new_rcursor = if cursor_is_beyond_end_of_current_row { + // keep same column + RCursor { + row: new_row, + column: cursor.rcursor.column, + } + } else { + // keep same X coord + let x = self.pos_from_cursor(cursor).center().x; + let column = if x > self.rows[new_row].max_x() { + // beyond the end of the next row - keep same column + cursor.rcursor.column + } else { + self.rows[new_row].char_at(x) + }; + RCursor { + row: new_row, + column, + } + }; + + self.from_rcursor(new_rcursor) + } else { + self.end() + } + } + + pub fn cursor_begin_of_row(&self, cursor: &Cursor) -> Cursor { + self.from_rcursor(RCursor { + row: cursor.rcursor.row, + column: 0, + }) + } + + pub fn cursor_end_of_row(&self, cursor: &Cursor) -> Cursor { + self.from_rcursor(RCursor { + row: cursor.rcursor.row, + column: self.rows[cursor.rcursor.row].char_count_excluding_newline(), + }) + } +} + +// ---------------------------------------------------------------------------- + +#[test] +fn test_text_layout() { + impl PartialEq for Cursor { + fn eq(&self, other: &Cursor) -> bool { + (self.ccursor, self.rcursor, self.pcursor) + == (other.ccursor, other.rcursor, other.pcursor) + } + } + + use crate::mutex::Mutex; + use crate::paint::{font::Font, *}; + + let pixels_per_point = 1.0; + let typeface_data = include_bytes!("../../fonts/ProggyClean.ttf"); + let atlas = TextureAtlas::new(512, 16); + let atlas = std::sync::Arc::new(Mutex::new(atlas)); + let font = Font::new(atlas, typeface_data, 13.0, pixels_per_point); + + let galley = font.layout_multiline("".to_owned(), 1024.0); + assert_eq!(galley.rows.len(), 1); + assert_eq!(galley.rows[0].ends_with_newline, false); + assert_eq!(galley.rows[0].x_offsets, vec![0.0]); + + let galley = font.layout_multiline("\n".to_owned(), 1024.0); + assert_eq!(galley.rows.len(), 2); + assert_eq!(galley.rows[0].ends_with_newline, true); + assert_eq!(galley.rows[1].ends_with_newline, false); + assert_eq!(galley.rows[1].x_offsets, vec![0.0]); + + let galley = font.layout_multiline("\n\n".to_owned(), 1024.0); + assert_eq!(galley.rows.len(), 3); + assert_eq!(galley.rows[0].ends_with_newline, true); + assert_eq!(galley.rows[1].ends_with_newline, true); + assert_eq!(galley.rows[2].ends_with_newline, false); + assert_eq!(galley.rows[2].x_offsets, vec![0.0]); + + let galley = font.layout_multiline(" ".to_owned(), 1024.0); + assert_eq!(galley.rows.len(), 1); + assert_eq!(galley.rows[0].ends_with_newline, false); + + let galley = font.layout_multiline("One row!".to_owned(), 1024.0); + assert_eq!(galley.rows.len(), 1); + assert_eq!(galley.rows[0].ends_with_newline, false); + + let galley = font.layout_multiline("First row!\n".to_owned(), 1024.0); + assert_eq!(galley.rows.len(), 2); + assert_eq!(galley.rows[0].ends_with_newline, true); + assert_eq!(galley.rows[1].ends_with_newline, false); + assert_eq!(galley.rows[1].x_offsets, vec![0.0]); + + let galley = font.layout_multiline("line\nbreak".to_owned(), 10.0); + assert_eq!(galley.rows.len(), 2); + assert_eq!(galley.rows[0].ends_with_newline, true); + assert_eq!(galley.rows[1].ends_with_newline, false); + + // Test wrapping: + let galley = font.layout_multiline("word wrap".to_owned(), 10.0); + assert_eq!(galley.rows.len(), 2); + assert_eq!(galley.rows[0].ends_with_newline, false); + assert_eq!(galley.rows[1].ends_with_newline, false); + + { + // Test wrapping: + let galley = font.layout_multiline("word wrap.\nNew paragraph.".to_owned(), 10.0); + assert_eq!(galley.rows.len(), 4); + assert_eq!(galley.rows[0].ends_with_newline, false); + assert_eq!(galley.rows[0].char_count_excluding_newline(), "word ".len()); + assert_eq!(galley.rows[0].char_count_including_newline(), "word ".len()); + assert_eq!(galley.rows[1].ends_with_newline, true); + assert_eq!(galley.rows[1].char_count_excluding_newline(), "wrap.".len()); + assert_eq!( + galley.rows[1].char_count_including_newline(), + "wrap.\n".len() + ); + assert_eq!(galley.rows[2].ends_with_newline, false); + assert_eq!(galley.rows[3].ends_with_newline, false); + + let cursor = Cursor::default(); + assert_eq!(cursor, galley.from_ccursor(cursor.ccursor)); + assert_eq!(cursor, galley.from_rcursor(cursor.rcursor)); + assert_eq!(cursor, galley.from_pcursor(cursor.pcursor)); + + let cursor = galley.end(); + assert_eq!(cursor, galley.from_ccursor(cursor.ccursor)); + assert_eq!(cursor, galley.from_rcursor(cursor.rcursor)); + assert_eq!(cursor, galley.from_pcursor(cursor.pcursor)); + assert_eq!( + cursor, + Cursor { + ccursor: CCursor::new(25), + rcursor: RCursor { row: 3, column: 10 }, + pcursor: PCursor { + paragraph: 1, + offset: 14, + prefer_next_row: false, + } + } + ); + + let cursor = galley.from_ccursor(CCursor::new(1)); + assert_eq!(cursor.rcursor, RCursor { row: 0, column: 1 }); + assert_eq!( + cursor.pcursor, + PCursor { + paragraph: 0, + offset: 1, + prefer_next_row: false, + } + ); + assert_eq!(cursor, galley.from_ccursor(cursor.ccursor)); + assert_eq!(cursor, galley.from_rcursor(cursor.rcursor)); + assert_eq!(cursor, galley.from_pcursor(cursor.pcursor)); + + let cursor = galley.from_pcursor(PCursor { + paragraph: 1, + offset: 2, + prefer_next_row: false, + }); + assert_eq!(cursor.rcursor, RCursor { row: 2, column: 2 }); + assert_eq!(cursor, galley.from_ccursor(cursor.ccursor)); + assert_eq!(cursor, galley.from_rcursor(cursor.rcursor)); + assert_eq!(cursor, galley.from_pcursor(cursor.pcursor)); + + let cursor = galley.from_pcursor(PCursor { + paragraph: 1, + offset: 6, + prefer_next_row: false, + }); + assert_eq!(cursor.rcursor, RCursor { row: 3, column: 2 }); + assert_eq!(cursor, galley.from_ccursor(cursor.ccursor)); + assert_eq!(cursor, galley.from_rcursor(cursor.rcursor)); + assert_eq!(cursor, galley.from_pcursor(cursor.pcursor)); + + // On the border between two rows within the same paragraph: + let cursor = galley.from_rcursor(RCursor { row: 0, column: 5 }); + assert_eq!( + cursor, + Cursor { + ccursor: CCursor::new(5), + rcursor: RCursor { row: 0, column: 5 }, + pcursor: PCursor { + paragraph: 0, + offset: 5, + prefer_next_row: false, + } + } + ); + assert_eq!(cursor, galley.from_rcursor(cursor.rcursor)); + + let cursor = galley.from_rcursor(RCursor { row: 1, column: 0 }); + assert_eq!( + cursor, + Cursor { + ccursor: CCursor::new(5), + rcursor: RCursor { row: 1, column: 0 }, + pcursor: PCursor { + paragraph: 0, + offset: 5, + prefer_next_row: false, + } + } + ); + assert_eq!(cursor, galley.from_rcursor(cursor.rcursor)); + } + + { + // Test cursor movement: + let galley = font.layout_multiline("word wrap.\nNew paragraph.".to_owned(), 10.0); + assert_eq!(galley.rows.len(), 4); + assert_eq!(galley.rows[0].ends_with_newline, false); + assert_eq!(galley.rows[1].ends_with_newline, true); + assert_eq!(galley.rows[2].ends_with_newline, false); + assert_eq!(galley.rows[3].ends_with_newline, false); + + let cursor = Cursor::default(); + + assert_eq!(galley.cursor_up_one_row(&cursor), cursor); + assert_eq!(galley.cursor_begin_of_row(&cursor), cursor); + + assert_eq!( + galley.cursor_end_of_row(&cursor), + Cursor { + ccursor: CCursor::new(5), + rcursor: RCursor { row: 0, column: 5 }, + pcursor: PCursor { + paragraph: 0, + offset: 5, + prefer_next_row: false, + } + } + ); + + assert_eq!( + galley.cursor_down_one_row(&cursor), + Cursor { + ccursor: CCursor::new(5), + rcursor: RCursor { row: 1, column: 0 }, + pcursor: PCursor { + paragraph: 0, + offset: 5, + prefer_next_row: false, + } + } + ); + + let cursor = Cursor::default(); + assert_eq!( + galley.cursor_down_one_row(&galley.cursor_down_one_row(&cursor)), + Cursor { + ccursor: CCursor::new(11), + rcursor: RCursor { row: 2, column: 0 }, + pcursor: PCursor { + paragraph: 1, + offset: 0, + prefer_next_row: false, + } + } + ); + + let cursor = galley.end(); + assert_eq!(galley.cursor_down_one_row(&cursor), cursor); + + let cursor = galley.end(); + assert!(galley.cursor_up_one_row(&galley.end()) != cursor); + + assert_eq!( + galley.cursor_up_one_row(&galley.end()), + Cursor { + ccursor: CCursor::new(15), + rcursor: RCursor { row: 2, column: 10 }, + pcursor: PCursor { + paragraph: 1, + offset: 4, + prefer_next_row: false, + } + } + ); + } +} diff --git a/egui/src/paint/mod.rs b/egui/src/paint/mod.rs index 6c87d7e81..b62aad3de 100644 --- a/egui/src/paint/mod.rs +++ b/egui/src/paint/mod.rs @@ -6,6 +6,7 @@ pub mod color; pub mod command; pub mod font; pub mod fonts; +mod galley; pub mod stats; pub mod tessellator; mod texture_atlas; @@ -14,9 +15,10 @@ pub use { color::{Rgba, Srgba}, command::{PaintCmd, Stroke}, fonts::{FontDefinitions, FontFamily, Fonts, TextStyle}, + galley::*, stats::PaintStats, tessellator::{ PaintJob, PaintJobs, TesselationOptions, TextureId, Triangles, Vertex, WHITE_UV, }, - texture_atlas::Texture, + texture_atlas::{Texture, TextureAtlas}, }; diff --git a/egui/src/paint/stats.rs b/egui/src/paint/stats.rs index f6fdbfdda..0b888a999 100644 --- a/egui/src/paint/stats.rs +++ b/egui/src/paint/stats.rs @@ -66,8 +66,8 @@ impl AllocInfo { } } - pub fn from_galley(galley: &font::Galley) -> Self { - Self::from_slice(galley.text.as_bytes()) + Self::from_slice(&galley.lines) + pub fn from_galley(galley: &Galley) -> Self { + Self::from_slice(galley.text.as_bytes()) + Self::from_slice(&galley.rows) } pub fn from_triangles(triangles: &Triangles) -> Self { diff --git a/egui/src/paint/tessellator.rs b/egui/src/paint/tessellator.rs index fdbdc3ce0..e10a5ec3d 100644 --- a/egui/src/paint/tessellator.rs +++ b/egui/src/paint/tessellator.rs @@ -787,13 +787,13 @@ fn tessellate_paint_command( let text_offset = vec2(0.0, 1.0); // Eye-balled for buttons. TODO: why is this needed? - let clip_rect = clip_rect.expand(2.0); // Some fudge to handle letter slightly larger than expected. + let clip_rect = clip_rect.expand(2.0); // Some fudge to handle letters that are slightly larger than expected. let font = &fonts[text_style]; let mut chars = galley.text.chars(); - for line in &galley.lines { + for line in &galley.rows { let line_min_y = pos.y + line.y_min + text_offset.x; - let line_max_y = line_min_y + font.height(); + let line_max_y = line_min_y + font.row_height(); let is_line_visible = line_max_y >= clip_rect.min.y && line_min_y <= clip_rect.max.y; @@ -820,6 +820,10 @@ fn tessellate_paint_command( out.add_rect_with_uv(pos, uv, color); } } + if line.ends_with_newline { + let newline = chars.next().unwrap(); + debug_assert_eq!(newline, '\n'); + } } assert_eq!(chars.next(), None); } diff --git a/egui/src/painter.rs b/egui/src/painter.rs index 2b3232ca5..67fec0ef9 100644 --- a/egui/src/painter.rs +++ b/egui/src/painter.rs @@ -5,7 +5,7 @@ use crate::{ color, layers::PaintCmdIdx, math::{Pos2, Rect, Vec2}, - paint::{font, Fonts, PaintCmd, Stroke, TextStyle}, + paint::{Fonts, Galley, PaintCmd, Stroke, TextStyle}, Context, LayerId, Srgba, }; @@ -278,7 +278,7 @@ impl Painter { } /// Paint text that has already been layed out in a `Galley`. - pub fn galley(&self, pos: Pos2, galley: font::Galley, text_style: TextStyle, color: Srgba) { + pub fn galley(&self, pos: Pos2, galley: Galley, text_style: TextStyle, color: Srgba) { self.add(PaintCmd::Text { pos, galley, diff --git a/egui/src/style.rs b/egui/src/style.rs index 99fa3ada5..ea11b7685 100644 --- a/egui/src/style.rs +++ b/egui/src/style.rs @@ -131,8 +131,6 @@ pub struct Visuals { pub resize_corner_size: f32, - /// Blink text cursor by this frequency. If 0, always show the cursor. - pub cursor_blink_hz: f32, pub text_cursor_width: f32, /// Allow child widgets to be just on the border and still have a stroke with some thickness @@ -260,7 +258,6 @@ impl Default for Visuals { dark_bg_color: Srgba::black_alpha(140), window_corner_radius: 10.0, resize_corner_size: 12.0, - cursor_blink_hz: 0.0, // 1.0 looks good text_cursor_width: 2.0, clip_rect_margin: 3.0, debug_widget_rects: false, @@ -444,7 +441,6 @@ impl Visuals { dark_bg_color, window_corner_radius, resize_corner_size, - cursor_blink_hz, text_cursor_width, clip_rect_margin, debug_widget_rects, @@ -455,7 +451,6 @@ impl Visuals { ui_color(ui, dark_bg_color, "dark_bg_color"); ui.add(Slider::f32(window_corner_radius, 0.0..=20.0).text("window_corner_radius")); ui.add(Slider::f32(resize_corner_size, 0.0..=20.0).text("resize_corner_size")); - ui.add(Slider::f32(cursor_blink_hz, 0.0..=4.0).text("cursor_blink_hz")); ui.add(Slider::f32(text_cursor_width, 0.0..=2.0).text("text_cursor_width")); ui.add(Slider::f32(clip_rect_margin, 0.0..=20.0).text("clip_rect_margin")); diff --git a/egui/src/cache.rs b/egui/src/util/cache.rs similarity index 100% rename from egui/src/cache.rs rename to egui/src/util/cache.rs diff --git a/egui/src/history.rs b/egui/src/util/history.rs similarity index 100% rename from egui/src/history.rs rename to egui/src/util/history.rs diff --git a/egui/src/util/mod.rs b/egui/src/util/mod.rs new file mode 100644 index 000000000..e780c6c31 --- /dev/null +++ b/egui/src/util/mod.rs @@ -0,0 +1,9 @@ +//! Tools used by Egui, but that doesn't depend on anything in Egui. + +pub(crate) mod cache; +mod history; +pub mod mutex; +pub mod undoer; + +pub(crate) use cache::Cache; +pub use history::History; diff --git a/egui/src/mutex.rs b/egui/src/util/mutex.rs similarity index 100% rename from egui/src/mutex.rs rename to egui/src/util/mutex.rs diff --git a/egui/src/util/undoer.rs b/egui/src/util/undoer.rs new file mode 100644 index 000000000..ec57173c4 --- /dev/null +++ b/egui/src/util/undoer.rs @@ -0,0 +1,172 @@ +use std::collections::VecDeque; + +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Settings { + /// Maximum number of undos. + /// If your state is resource intensive, you should keep this low. + /// + /// Default: `100` + pub max_undos: usize, + + /// When that state hasn't changed for this many seconds, + /// create a new undo point (if one is needed). + /// + /// Default value: `1.0` seconds. + pub stable_time: f32, + + /// If the state is changing so often that we never get to `stable_time`, + /// then still create a save point every `auto_save_interval` seconds, + /// so we have something to undo to. + /// + /// Default value: `30` seconds. + pub auto_save_interval: f32, +} + +impl Default for Settings { + fn default() -> Self { + Self { + max_undos: 100, + stable_time: 1.0, + auto_save_interval: 30.0, + } + } +} + +/// Automatic undo system. +/// +/// Every frame you feed it the most recent state. +/// The `Undoer` compares it with the latest undo point +/// and if there is a change it may create a new undo point. +/// +/// `Undoer` follows two simple rules: +/// +/// 1) If the state has changed since the latest undo point, but has +/// remained stable for `stable_time` seconds, an new undo point is created. +/// 2) If the state does not stabilize within `auto_save_interval` seconds, an undo point is created. +/// +/// Rule 1) will make sure an undo point is not created until you _stop_ dragging that slider. +/// Rule 2) will make sure that you will get some undo points even if you are constantly changing the state. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Undoer { + settings: Settings, + + /// New undoes are added to the back. + /// Two adjacent undo points are never equal. + /// The latest undo point may (often) be the current state. + undos: VecDeque, + + #[cfg_attr(feature = "serde", serde(skip))] + flux: Option>, +} + +impl std::fmt::Debug for Undoer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { undos, .. } = self; + f.debug_struct("Undoer") + .field("undo count", &undos.len()) + .finish() + } +} + +/// Represents how the current state is changing +#[derive(Clone)] +struct Flux { + start_time: f64, + latest_change_time: f64, + latest_state: State, +} + +impl Undoer +where + State: Clone + PartialEq, +{ + /// Do we have an undo point different from the given state? + pub fn has_undo(&self, current_state: &State) -> bool { + match self.undos.len() { + 0 => false, + 1 => self.undos.back() != Some(current_state), + _ => true, + } + } + + /// Return true if the state is currently changing + pub fn is_in_flux(&self) -> bool { + self.flux.is_some() + } + + pub fn undo(&mut self, current_state: &State) -> Option<&State> { + if self.has_undo(current_state) { + self.flux = None; + + if self.undos.back() == Some(current_state) { + self.undos.pop_back(); + } + + // Note: we keep the undo point intact. + self.undos.back() + } else { + None + } + } + + /// Add an undo point if, and only if, there has been a change since the latest undo point. + /// + /// * `time`: current time in seconds. + pub fn add_undo(&mut self, current_state: &State) { + if self.undos.back() != Some(current_state) { + self.undos.push_back(current_state.clone()); + } + while self.undos.len() > self.settings.max_undos { + self.undos.pop_front(); + } + self.flux = None; + } + + /// Call this as often as you want (e.g. every frame) + /// and `Undoer` will determine if a new undo point should be created. + /// + /// * `current_time`: current time in seconds. + pub fn feed_state(&mut self, current_time: f64, current_state: &State) { + match self.undos.back() { + None => { + // First time feed_state is called. + // always create an undo point: + self.add_undo(current_state); + } + Some(latest_undo) => { + if latest_undo == current_state { + self.flux = None; + } else { + match self.flux.as_mut() { + None => { + self.flux = Some(Flux { + start_time: current_time, + latest_change_time: current_time, + latest_state: current_state.clone(), + }); + } + Some(flux) => { + if &flux.latest_state == current_state { + let time_since_latest_change = + (current_time - flux.latest_change_time) as f32; + if time_since_latest_change >= self.settings.stable_time { + self.add_undo(current_state); + } + } else { + let time_since_flux_start = (current_time - flux.start_time) as f32; + if time_since_flux_start >= self.settings.auto_save_interval { + self.add_undo(current_state); + } else { + flux.latest_change_time = current_time; + flux.latest_state = current_state.clone(); + } + } + } + } + } + } + } + } +} diff --git a/egui/src/widgets/mod.rs b/egui/src/widgets/mod.rs index 0bac8eb29..63b088aca 100644 --- a/egui/src/widgets/mod.rs +++ b/egui/src/widgets/mod.rs @@ -78,7 +78,7 @@ impl Label { self } - pub fn layout(&self, ui: &Ui) -> font::Galley { + pub fn layout(&self, ui: &Ui) -> Galley { let max_width = ui.available().width(); // Prevent word-wrapping after a single letter, and other silly shit: // TODO: general "don't force labels and similar to wrap so early" @@ -86,7 +86,7 @@ impl Label { self.layout_width(ui, max_width) } - pub fn layout_width(&self, ui: &Ui, max_width: f32) -> font::Galley { + pub fn layout_width(&self, ui: &Ui, max_width: f32) -> Galley { let text_style = self.text_style_or_default(ui.style()); let font = &ui.fonts()[text_style]; if self.multiline { @@ -98,7 +98,7 @@ impl Label { pub fn font_height(&self, fonts: &Fonts, style: &Style) -> f32 { let text_style = self.text_style_or_default(style); - fonts[text_style].height() + fonts[text_style].row_height() } // TODO: this should return a LabelLayout which has a paint method. @@ -109,7 +109,7 @@ impl Label { // TODO: a paint method for painting anywhere in a ui. // This should be the easiest method of putting text anywhere. - pub fn paint_galley(&self, ui: &mut Ui, pos: Pos2, galley: font::Galley) { + pub fn paint_galley(&self, ui: &mut Ui, pos: Pos2, galley: Galley) { let text_style = self.text_style_or_default(ui.style()); let text_color = self .text_color @@ -222,7 +222,7 @@ impl Widget for Hyperlink { if response.hovered { // Underline: - for line in &galley.lines { + for line in &galley.rows { let pos = response.rect.min; let y = pos.y + line.y_max; let y = ui.painter().round_to_pixel(y); diff --git a/egui/src/widgets/slider.rs b/egui/src/widgets/slider.rs index 5e653d1aa..5e38956a2 100644 --- a/egui/src/widgets/slider.rs +++ b/egui/src/widgets/slider.rs @@ -367,7 +367,7 @@ impl<'a> Widget for Slider<'a> { let text_style = TextStyle::Button; let font = &ui.fonts()[text_style]; let height = font - .line_spacing() + .row_height() .at_least(ui.style().spacing.interact_size.y); if self.text.is_some() { diff --git a/egui/src/widgets/text_edit.rs b/egui/src/widgets/text_edit.rs index 0159b0bf1..bf50aae6c 100644 --- a/egui/src/widgets/text_edit.rs +++ b/egui/src/widgets/text_edit.rs @@ -1,11 +1,99 @@ -use crate::{paint::*, *}; +use crate::{paint::*, util::undoer::Undoer, *}; -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Debug, Default)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] pub(crate) struct State { - /// Character based, NOT bytes. - /// TODO: store as line + row - pub cursor: Option, + cursorp: Option, + + #[cfg_attr(feature = "serde", serde(skip))] + undoer: Undoer<(CCursorPair, String)>, +} + +#[derive(Clone, Copy, Debug, Default)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +struct CursorPair { + /// When selecting with a mouse, this is where the mouse was released. + /// When moving with e.g. shift+arrows, this is what moves. + /// Note that the two ends can come in any order, and also be equal (no selection). + pub primary: Cursor, + + /// When selecting with a mouse, this is where the mouse was first pressed. + /// This part of the cursor does not move when shift is down. + pub secondary: Cursor, +} + +impl CursorPair { + fn one(cursor: Cursor) -> Self { + Self { + primary: cursor, + secondary: cursor, + } + } + + fn two(min: Cursor, max: Cursor) -> Self { + Self { + primary: max, + secondary: min, + } + } + + fn as_ccursorp(&self) -> CCursorPair { + CCursorPair { + primary: self.primary.ccursor, + secondary: self.secondary.ccursor, + } + } + + fn is_empty(&self) -> bool { + self.primary.ccursor == self.secondary.ccursor + } + + /// If there is a selection, None is returned. + /// If the two ends is the same, that is returned. + fn single(&self) -> Option { + if self.is_empty() { + Some(self.primary) + } else { + None + } + } + + fn primary_is_first(&self) -> bool { + let p = self.primary.ccursor; + let s = self.secondary.ccursor; + (p.index, p.prefer_next_row) <= (s.index, s.prefer_next_row) + } + + fn sorted(&self) -> [Cursor; 2] { + if self.primary_is_first() { + [self.primary, self.secondary] + } else { + [self.secondary, self.primary] + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +struct CCursorPair { + /// When selecting with a mouse, this is where the mouse was released. + /// When moving with e.g. shift+arrows, this is what moves. + /// Note that the two ends can come in any order, and also be equal (no selection). + pub primary: CCursor, + + /// When selecting with a mouse, this is where the mouse was first pressed. + /// This part of the cursor does not move when shift is down. + pub secondary: CCursor, +} + +impl CCursorPair { + fn one(ccursor: CCursor) -> Self { + Self { + primary: ccursor, + secondary: ccursor, + } + } } /// A text region that the user can edit the contents of. @@ -142,7 +230,7 @@ impl<'t> Widget for TextEdit<'t> { let text_style = text_style.unwrap_or_else(|| ui.style().body_text_style); let font = &ui.fonts()[text_style]; - let line_spacing = font.line_spacing(); + let line_spacing = font.row_height(); let available_width = ui.available().width(); let mut galley = if multiline { font.layout_multiline(text.clone(), available_width) @@ -162,17 +250,59 @@ impl<'t> Widget for TextEdit<'t> { } else { Sense::nothing() }; - let response = ui.interact(rect, id, sense); // TODO: implement drag-select + let response = ui.interact(rect, id, sense); + + if enabled { + ui.memory().interested_in_kb_focus(id); + } - if response.clicked && enabled { - ui.memory().request_kb_focus(id); + if enabled { if let Some(mouse_pos) = ui.input().mouse.pos { - state.cursor = Some(galley.char_at(mouse_pos - response.rect.min).char_idx); + // TODO: triple-click to select whole paragraph + // TODO: drag selected text to either move or clone (ctrl on windows, alt on mac) + + let cursor_at_mouse = galley.cursor_from_pos(mouse_pos - response.rect.min); + + if response.hovered { + // preview: + let end_color = Rgba::new(0.1, 0.6, 1.0, 1.0).multiply(0.5).into(); // TODO: from style + paint_cursor_end(ui, response.rect.min, &galley, &cursor_at_mouse, end_color); + } + + if response.hovered && response.double_clicked { + // Select word: + let center = cursor_at_mouse; + let primary = + galley.from_ccursor(ccursor_next_word(&galley.text, center.ccursor)); + state.cursorp = Some(CursorPair { + secondary: galley + .from_ccursor(ccursor_previous_word(&galley.text, primary.ccursor)), + primary, + }); + } else if response.hovered && ui.input().mouse.pressed { + ui.memory().request_kb_focus(id); + if ui.input().modifiers.shift { + if let Some(cursorp) = &mut state.cursorp { + cursorp.primary = cursor_at_mouse; + } else { + state.cursorp = Some(CursorPair::one(cursor_at_mouse)); + } + } else { + state.cursorp = Some(CursorPair::one(cursor_at_mouse)); + } + } else if ui.input().mouse.down && response.active { + if let Some(cursorp) = &mut state.cursorp { + cursorp.primary = cursor_at_mouse; + } + } } - } else if ui.input().mouse.click || (ui.input().mouse.pressed && !response.hovered) { + } + + if ui.input().mouse.pressed && !response.hovered { // User clicked somewhere else ui.memory().surrender_kb_focus(id); } + if !enabled { ui.memory().surrender_kb_focus(id); } @@ -182,27 +312,70 @@ impl<'t> Widget for TextEdit<'t> { } if ui.memory().has_kb_focus(id) && enabled { - let mut cursor = state.cursor.unwrap_or_else(|| text.chars().count()); - cursor = clamp(cursor, 0..=text.chars().count()); + let mut cursorp = state + .cursorp + .map(|cursorp| { + // We only keep the PCursor (paragraph number, and character offset within that paragraph). + // This is so what if we resize the `TextEdit` region, and text wrapping changes, + // we keep the same byte character offset from the beginning of the text, + // even though the number of rows changes + // (each paragraph can be several rows, due to word wrapping). + // The column (character offset) should be able to extend beyond the last word so that we can + // go down and still end up on the same column when we return. + CursorPair { + primary: galley.from_pcursor(cursorp.primary.pcursor), + secondary: galley.from_pcursor(cursorp.secondary.pcursor), + } + }) + .unwrap_or_else(|| CursorPair::one(galley.end())); + + // We feed state to the undoer both before and after handling input + // so that the undoer creates automatic saves even when there are no events for a while. + state + .undoer + .feed_state(ui.input().time, &(cursorp.as_ccursorp(), text.clone())); for event in &ui.input().events { - match event { - Event::Copy | Event::Cut => { - // TODO: cut - ui.ctx().output().copied_text = text.clone(); + let did_mutate_text = match event { + Event::Copy => { + if cursorp.is_empty() { + ui.ctx().output().copied_text = text.clone(); + } else { + ui.ctx().output().copied_text = selected_str(text, &cursorp).to_owned(); + } + None + } + Event::Cut => { + if cursorp.is_empty() { + ui.ctx().output().copied_text = std::mem::take(text); + Some(CCursorPair::default()) + } else { + ui.ctx().output().copied_text = selected_str(text, &cursorp).to_owned(); + Some(CCursorPair::one(delete_selected(text, &cursorp))) + } } Event::Text(text_to_insert) => { - // newlines are handled by `Key::Enter`. - if text_to_insert != "\n" && text_to_insert != "\r" { - insert_text(&mut cursor, text, text_to_insert); + // Newlines are handled by `Key::Enter`. + if !text_to_insert.is_empty() + && text_to_insert != "\n" + && text_to_insert != "\r" + { + let mut ccursor = delete_selected(text, &cursorp); + insert_text(&mut ccursor, text, text_to_insert); + Some(CCursorPair::one(ccursor)) + } else { + None } } Event::Key { key: Key::Enter, pressed: true, + .. } => { if multiline { - insert_text(&mut cursor, text, "\n"); + let mut ccursor = delete_selected(text, &cursorp); + insert_text(&mut ccursor, text, "\n"); + Some(CCursorPair::one(ccursor)) } else { // Common to end input with enter ui.memory().surrender_kb_focus(id); @@ -212,35 +385,64 @@ impl<'t> Widget for TextEdit<'t> { Event::Key { key: Key::Escape, pressed: true, + .. } => { ui.memory().surrender_kb_focus(id); break; } - Event::Key { key, pressed: true } => { - on_key_press(&mut cursor, text, *key); + + Event::Key { + key: Key::Z, + pressed: true, + modifiers, + } if modifiers.command && !modifiers.shift => { + // TODO: redo + if let Some((undo_ccursorp, undo_txt)) = + state.undoer.undo(&(cursorp.as_ccursorp(), text.clone())) + { + *text = undo_txt.clone(); + Some(*undo_ccursorp) + } else { + None + } } - _ => {} + + Event::Key { + key, + pressed: true, + modifiers, + } => on_key_press(&mut cursorp, text, &galley, *key, modifiers), + + Event::Key { .. } => None, + }; + + if let Some(new_ccursorp) = did_mutate_text { + // Layout again to avoid frame delay, and to keep `text` and `galley` in sync. + let font = &ui.fonts()[text_style]; + galley = if multiline { + font.layout_multiline(text.clone(), available_width) + } else { + font.layout_single_line(text.clone()) + }; + + // Set cursorp using new galley: + cursorp = CursorPair { + primary: galley.from_ccursor(new_ccursorp.primary), + secondary: galley.from_ccursor(new_ccursorp.secondary), + }; } } - state.cursor = Some(cursor); + state.cursorp = Some(cursorp); - // layout again to avoid frame delay: - let font = &ui.fonts()[text_style]; - galley = if multiline { - font.layout_multiline(text.clone(), available_width) - } else { - font.layout_single_line(text.clone()) - }; - - // dbg!(&galley); + state + .undoer + .feed_state(ui.input().time, &(cursorp.as_ccursorp(), text.clone())); } - let painter = ui.painter(); - let visuals = ui.style().interact(&response); - { + let visuals = ui.style().interact(&response); let bg_rect = response.rect.expand(2.0); // breathing room for content - painter.add(PaintCmd::Rect { + ui.painter().add(PaintCmd::Rect { rect: bg_rect, corner_radius: visuals.corner_radius, fill: ui.style().visuals.dark_bg_color, @@ -250,29 +452,22 @@ impl<'t> Widget for TextEdit<'t> { } if ui.memory().has_kb_focus(id) { - let cursor_blink_hz = ui.style().visuals.cursor_blink_hz; - let show_cursor = if 0.0 < cursor_blink_hz { - ui.ctx().request_repaint(); // TODO: only when cursor blinks on or off - (ui.input().time * cursor_blink_hz as f64 * 3.0).floor() as i64 % 3 != 0 - } else { - true - }; - - if show_cursor { - if let Some(cursor) = state.cursor { - let cursor_pos = response.rect.min + galley.char_start_pos(cursor); - painter.line_segment( - [cursor_pos, cursor_pos + vec2(0.0, line_spacing)], - (ui.style().visuals.text_cursor_width, color::WHITE), - ); - } + if let Some(cursorp) = state.cursorp { + // TODO: color from Style + let selection_color = Rgba::new(0.0, 0.5, 1.0, 0.0).multiply(0.15).into(); // additive! + let end_color = Rgba::new(0.3, 0.6, 1.0, 1.0).into(); + paint_cursor_selection(ui, response.rect.min, &galley, &cursorp, selection_color); + paint_cursor_end(ui, response.rect.min, &galley, &cursorp.primary, end_color); } } let text_color = text_color .or(ui.style().visuals.override_text_color) - .unwrap_or_else(|| visuals.text_color()); - painter.galley(response.rect.min, galley, text_style, text_color); + // .unwrap_or_else(|| ui.style().interact(&response).text_color()); // too bright + .unwrap_or_else(|| ui.style().visuals.widgets.inactive.text_color()); + ui.painter() + .galley(response.rect.min, galley, text_style, text_color); + ui.memory().text_edit.insert(id, state); Response { @@ -282,114 +477,363 @@ impl<'t> Widget for TextEdit<'t> { } } -fn insert_text(cursor: &mut usize, text: &mut String, text_to_insert: &str) { - // eprintln!("insert_text {:?}", text_to_insert); +// ---------------------------------------------------------------------------- + +fn paint_cursor_selection( + ui: &mut Ui, + pos: Pos2, + galley: &Galley, + cursorp: &CursorPair, + color: Srgba, +) { + if cursorp.is_empty() { + return; + } + let [min, max] = cursorp.sorted(); + let min = min.rcursor; + let max = max.rcursor; + + for ri in min.row..=max.row { + let row = &galley.rows[ri]; + let left = if ri == min.row { + row.x_offset(min.column) + } else { + row.min_x() + }; + let right = if ri == max.row { + row.x_offset(max.column) + } else { + let newline_size = if row.ends_with_newline { + row.height() / 2.0 // visualize that we select the newline + } else { + 0.0 + }; + row.max_x() + newline_size + }; + let rect = Rect::from_min_max(pos + vec2(left, row.y_min), pos + vec2(right, row.y_max)); + ui.painter().rect_filled(rect, 0.0, color); + } +} + +fn paint_cursor_end(ui: &mut Ui, pos: Pos2, galley: &Galley, cursor: &Cursor, color: Srgba) { + let cursor_pos = galley.pos_from_cursor(cursor).translate(pos.to_vec2()); + let cursor_pos = cursor_pos.expand(1.5); // slightly above/below row + + let top = cursor_pos.center_top(); + let bottom = cursor_pos.center_bottom(); + + ui.painter() + .line_segment([top, bottom], (ui.style().visuals.text_cursor_width, color)); + + if false { + // Roof/floor: + let extrusion = 3.0; + let width = 1.0; + ui.painter().line_segment( + [top - vec2(extrusion, 0.0), top + vec2(extrusion, 0.0)], + (width, color), + ); + ui.painter().line_segment( + [bottom - vec2(extrusion, 0.0), bottom + vec2(extrusion, 0.0)], + (width, color), + ); + } +} + +// ---------------------------------------------------------------------------- + +fn selected_str<'s>(text: &'s str, cursorp: &CursorPair) -> &'s str { + let [min, max] = cursorp.sorted(); + let byte_begin = byte_index_from_char_index(text, min.ccursor.index); + let byte_end = byte_index_from_char_index(text, max.ccursor.index); + &text[byte_begin..byte_end] +} + +fn byte_index_from_char_index(s: &str, char_index: usize) -> usize { + for (ci, (bi, _)) in s.char_indices().enumerate() { + if ci == char_index { + return bi; + } + } + s.len() +} +fn insert_text(ccursor: &mut CCursor, text: &mut String, text_to_insert: &str) { let mut char_it = text.chars(); - let mut new_text = String::with_capacity(text.capacity()); - for _ in 0..*cursor { + let mut new_text = String::with_capacity(text.len() + text_to_insert.len()); + for _ in 0..ccursor.index { let c = char_it.next().unwrap(); new_text.push(c); } - *cursor += text_to_insert.chars().count(); + ccursor.index += text_to_insert.chars().count(); new_text += text_to_insert; new_text.extend(char_it); *text = new_text; } -fn on_key_press(cursor: &mut usize, text: &mut String, key: Key) { - // eprintln!("on_key_press before: '{}', cursor at {}", text, cursor); +// ---------------------------------------------------------------------------- - match key { - Key::Backspace if *cursor > 0 => { - *cursor -= 1; +fn delete_selected(text: &mut String, cursorp: &CursorPair) -> CCursor { + let [min, max] = cursorp.sorted(); + delete_selected_ccursor_range(text, [min.ccursor, max.ccursor]) +} - let mut char_it = text.chars(); - let mut new_text = String::with_capacity(text.capacity()); - for _ in 0..*cursor { - new_text.push(char_it.next().unwrap()) - } - new_text.extend(char_it.skip(1)); - *text = new_text; +fn delete_selected_ccursor_range(text: &mut String, [min, max]: [CCursor; 2]) -> CCursor { + let [min, max] = [min.index, max.index]; + assert!(min <= max); + if min < max { + let mut char_it = text.chars(); + let mut new_text = String::with_capacity(text.len()); + for _ in 0..min { + new_text.push(char_it.next().unwrap()) } - Key::Delete => { - let mut char_it = text.chars(); - let mut new_text = String::with_capacity(text.capacity()); - for _ in 0..*cursor { - new_text.push(char_it.next().unwrap()) - } - new_text.extend(char_it.skip(1)); - *text = new_text; + new_text.extend(char_it.skip(max - min)); + *text = new_text; + } + CCursor { + index: min, + prefer_next_row: true, + } +} + +fn delete_previous_char(text: &mut String, ccursor: CCursor) -> CCursor { + if ccursor.index > 0 { + let max_ccursor = ccursor; + let min_ccursor = max_ccursor - 1; + delete_selected_ccursor_range(text, [min_ccursor, max_ccursor]) + } else { + ccursor + } +} + +fn delete_next_char(text: &mut String, ccursor: CCursor) -> CCursor { + delete_selected_ccursor_range(text, [ccursor, ccursor + 1]) +} + +fn delete_previous_word(text: &mut String, max_ccursor: CCursor) -> CCursor { + let min_ccursor = ccursor_previous_word(text, max_ccursor); + delete_selected_ccursor_range(text, [min_ccursor, max_ccursor]) +} + +fn delete_next_word(text: &mut String, min_ccursor: CCursor) -> CCursor { + let max_ccursor = ccursor_next_word(text, min_ccursor); + delete_selected_ccursor_range(text, [min_ccursor, max_ccursor]) +} + +fn delete_paragraph_before_cursor( + text: &mut String, + galley: &Galley, + cursorp: &CursorPair, +) -> CCursor { + let [min, max] = cursorp.sorted(); + let min = galley.from_pcursor(PCursor { + paragraph: min.pcursor.paragraph, + offset: 0, + prefer_next_row: true, + }); + if min.ccursor == max.ccursor { + delete_previous_char(text, min.ccursor) + } else { + delete_selected(text, &CursorPair::two(min, max)) + } +} + +fn delete_paragraph_after_cursor( + text: &mut String, + galley: &Galley, + cursorp: &CursorPair, +) -> CCursor { + let [min, max] = cursorp.sorted(); + let max = galley.from_pcursor(PCursor { + paragraph: max.pcursor.paragraph, + offset: usize::MAX, // end of paragraph + prefer_next_row: false, + }); + if min.ccursor == max.ccursor { + delete_next_char(text, min.ccursor) + } else { + delete_selected(text, &CursorPair::two(min, max)) + } +} + +// ---------------------------------------------------------------------------- + +/// Returns `Some(new_cursor)` if we did mutate `text`. +fn on_key_press( + cursorp: &mut CursorPair, + text: &mut String, + galley: &Galley, + key: Key, + modifiers: &Modifiers, +) -> Option { + match key { + Key::Backspace => { + let ccursor = if modifiers.mac_cmd { + delete_paragraph_before_cursor(text, galley, cursorp) + } else if let Some(cursor) = cursorp.single() { + if modifiers.alt || modifiers.ctrl { + // alt on mac, ctrl on windows + delete_previous_word(text, cursor.ccursor) + } else { + delete_previous_char(text, cursor.ccursor) + } + } else { + delete_selected(text, cursorp) + }; + Some(CCursorPair::one(ccursor)) } - Key::Enter => {} // handled earlier - Key::Home => { - // To start of paragraph: - let pos = line_col_from_char_idx(text, *cursor); - *cursor = char_idx_from_line_col(text, (pos.0, 0)); + Key::Delete => { + let ccursor = if modifiers.mac_cmd { + delete_paragraph_after_cursor(text, galley, cursorp) + } else if let Some(cursor) = cursorp.single() { + if modifiers.alt || modifiers.ctrl { + // alt on mac, ctrl on windows + delete_next_word(text, cursor.ccursor) + } else { + delete_next_char(text, cursor.ccursor) + } + } else { + delete_selected(text, cursorp) + }; + let ccursor = CCursor { + prefer_next_row: true, + ..ccursor + }; + Some(CCursorPair::one(ccursor)) } - Key::End => { - // To end of paragraph: - let pos = line_col_from_char_idx(text, *cursor); - let line = line_from_number(text, pos.0); - *cursor = char_idx_from_line_col(text, (pos.0, line.chars().count())); + + Key::A if modifiers.command => { + // select all + *cursorp = CursorPair::two(Cursor::default(), galley.end()); + None } - Key::Left if *cursor > 0 => { - *cursor -= 1; + + Key::K if modifiers.ctrl => { + let ccursor = delete_paragraph_after_cursor(text, galley, cursorp); + Some(CCursorPair::one(ccursor)) } - Key::Right => { - *cursor = (*cursor + 1).min(text.chars().count()); + + Key::U if modifiers.ctrl => { + let ccursor = delete_paragraph_before_cursor(text, galley, cursorp); + Some(CCursorPair::one(ccursor)) } - Key::Up => { - let mut pos = line_col_from_char_idx(text, *cursor); - pos.0 = pos.0.saturating_sub(1); - *cursor = char_idx_from_line_col(text, pos); + + Key::W if modifiers.ctrl => { + let ccursor = if let Some(cursor) = cursorp.single() { + delete_previous_word(text, cursor.ccursor) + } else { + delete_selected(text, cursorp) + }; + Some(CCursorPair::one(ccursor)) } - Key::Down => { - let mut pos = line_col_from_char_idx(text, *cursor); - pos.0 += 1; - *cursor = char_idx_from_line_col(text, pos); + + Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End => { + move_single_cursor(&mut cursorp.primary, galley, key, modifiers); + if !modifiers.shift { + cursorp.secondary = cursorp.primary; + } + None } - _ => {} - } - // eprintln!("on_key_press after: '{}', cursor at {}\n", text, cursor); + _ => None, + } } -fn line_col_from_char_idx(s: &str, char_idx: usize) -> (usize, usize) { - let mut char_count = 0; +fn move_single_cursor(cursor: &mut Cursor, galley: &Galley, key: Key, modifiers: &Modifiers) { + match key { + Key::ArrowLeft => { + if modifiers.alt || modifiers.ctrl { + // alt on mac, ctrl on windows + *cursor = galley.from_ccursor(ccursor_previous_word(&galley.text, cursor.ccursor)); + } else if modifiers.mac_cmd { + *cursor = galley.cursor_begin_of_row(cursor); + } else { + *cursor = galley.cursor_left_one_character(cursor); + } + } + Key::ArrowRight => { + if modifiers.alt || modifiers.ctrl { + // alt on mac, ctrl on windows + *cursor = galley.from_ccursor(ccursor_next_word(&galley.text, cursor.ccursor)); + } else if modifiers.mac_cmd { + *cursor = galley.cursor_end_of_row(cursor); + } else { + *cursor = galley.cursor_right_one_character(cursor); + } + } + Key::ArrowUp => { + if modifiers.command { + // mac and windows behavior + *cursor = Cursor::default(); + } else { + *cursor = galley.cursor_up_one_row(cursor); + } + } + Key::ArrowDown => { + if modifiers.command { + // mac and windows behavior + *cursor = galley.end(); + } else { + *cursor = galley.cursor_down_one_row(cursor); + } + } - let mut last_line_nr = 0; - let mut last_line = s; - for (line_nr, line) in s.split('\n').enumerate() { - let line_width = line.chars().count(); - if char_idx <= char_count + line_width { - return (line_nr, char_idx - char_count); + Key::Home => { + if modifiers.ctrl { + // windows behavior + *cursor = Cursor::default(); + } else { + *cursor = galley.cursor_begin_of_row(cursor); + } + } + Key::End => { + if modifiers.ctrl { + // windows behavior + *cursor = galley.end(); + } else { + *cursor = galley.cursor_end_of_row(cursor); + } } - char_count += line_width + 1; - last_line_nr = line_nr; - last_line = line; + + _ => unreachable!(), } +} + +// ---------------------------------------------------------------------------- - // safe fallback: - (last_line_nr, last_line.chars().count()) +fn ccursor_next_word(text: &str, ccursor: CCursor) -> CCursor { + CCursor { + index: next_word_char_index(text.chars(), ccursor.index), + prefer_next_row: false, + } } -fn char_idx_from_line_col(s: &str, pos: (usize, usize)) -> usize { - let mut char_count = 0; - for (line_nr, line) in s.split('\n').enumerate() { - if line_nr == pos.0 { - return char_count + pos.1.min(line.chars().count()); - } - char_count += line.chars().count() + 1; +fn ccursor_previous_word(text: &str, ccursor: CCursor) -> CCursor { + let num_chars = text.chars().count(); + CCursor { + index: num_chars - next_word_char_index(text.chars().rev(), num_chars - ccursor.index), + prefer_next_row: true, } - char_count } -fn line_from_number(s: &str, desired_line_number: usize) -> &str { - for (line_nr, line) in s.split('\n').enumerate() { - if line_nr == desired_line_number { - return line; +fn next_word_char_index(it: impl Iterator, mut index: usize) -> usize { + let mut it = it.skip(index); + if let Some(_first) = it.next() { + index += 1; + + if let Some(second) = it.next() { + index += 1; + for next in it { + if is_word_char(next) != is_word_char(second) { + break; + } + index += 1; + } } } - s + index +} + +fn is_word_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' } diff --git a/egui_glium/src/lib.rs b/egui_glium/src/lib.rs index 4a3c1333a..0936aef09 100644 --- a/egui_glium/src/lib.rs +++ b/egui_glium/src/lib.rs @@ -20,10 +20,6 @@ pub use clipboard::ClipboardContext; // TODO: remove pub struct GliumInputState { raw: egui::RawInput, - - /// Command modifier key. - /// Mac command key on Mac, ctrl on Window/Linux. - cmd: bool, } impl GliumInputState { @@ -33,7 +29,6 @@ impl GliumInputState { pixels_per_point: Some(pixels_per_point), ..Default::default() }, - cmd: false, } } } @@ -63,40 +58,51 @@ pub fn input_to_egui( input_state.raw.mouse_pos = None; } ReceivedCharacter(ch) => { - if !input_state.cmd && printable_char(ch) { + if printable_char(ch) + && !input_state.raw.modifiers.ctrl + && !input_state.raw.modifiers.mac_cmd + { input_state.raw.events.push(Event::Text(ch.to_string())); } } KeyboardInput { input, .. } => { - if let Some(virtual_keycode) = input.virtual_keycode { - let is_command_key = if cfg!(target_os = "macos") { - matches!(virtual_keycode, VirtualKeyCode::LWin | VirtualKeyCode::RWin) - } else { - matches!( - virtual_keycode, - VirtualKeyCode::LControl | VirtualKeyCode::RControl - ) - }; - - if is_command_key { - input_state.cmd = input.state == glutin::event::ElementState::Pressed; + if let Some(keycode) = input.virtual_keycode { + let pressed = input.state == glutin::event::ElementState::Pressed; + + if matches!(keycode, VirtualKeyCode::LAlt | VirtualKeyCode::RAlt) { + input_state.raw.modifiers.alt = pressed; + } + if matches!(keycode, VirtualKeyCode::LControl | VirtualKeyCode::RControl) { + input_state.raw.modifiers.ctrl = pressed; + if !cfg!(target_os = "macos") { + input_state.raw.modifiers.command = pressed; + } + } + if matches!(keycode, VirtualKeyCode::LShift | VirtualKeyCode::RShift) { + input_state.raw.modifiers.shift = pressed; + } + if cfg!(target_os = "macos") + && matches!(keycode, VirtualKeyCode::LWin | VirtualKeyCode::RWin) + { + input_state.raw.modifiers.mac_cmd = pressed; + input_state.raw.modifiers.command = pressed; } - if input.state == glutin::event::ElementState::Pressed { + if pressed { if cfg!(target_os = "macos") - && input_state.cmd - && virtual_keycode == VirtualKeyCode::Q + && input_state.raw.modifiers.mac_cmd + && keycode == VirtualKeyCode::Q { *control_flow = ControlFlow::Exit; } // VirtualKeyCode::Paste etc in winit are broken/untrustworthy, // so we detect these things manually: - if input_state.cmd && virtual_keycode == VirtualKeyCode::X { + if input_state.raw.modifiers.command && keycode == VirtualKeyCode::X { input_state.raw.events.push(Event::Cut); - } else if input_state.cmd && virtual_keycode == VirtualKeyCode::C { + } else if input_state.raw.modifiers.command && keycode == VirtualKeyCode::C { input_state.raw.events.push(Event::Copy); - } else if input_state.cmd && virtual_keycode == VirtualKeyCode::V { + } else if input_state.raw.modifiers.command && keycode == VirtualKeyCode::V { if let Some(clipboard) = clipboard { match clipboard.get_contents() { Ok(contents) => { @@ -107,10 +113,11 @@ pub fn input_to_egui( } } } - } else if let Some(key) = translate_virtual_key_code(virtual_keycode) { + } else if let Some(key) = translate_virtual_key_code(keycode) { input_state.raw.events.push(Event::Key { key, - pressed: input.state == glutin::event::ElementState::Pressed, + pressed, + modifiers: input_state.raw.modifiers, }); } } @@ -157,19 +164,19 @@ pub fn translate_virtual_key_code(key: VirtualKeyCode) -> Option { End => Key::End, PageDown => Key::PageDown, PageUp => Key::PageUp, - Left => Key::Left, - Up => Key::Up, - Right => Key::Right, - Down => Key::Down, + Left => Key::ArrowLeft, + Up => Key::ArrowUp, + Right => Key::ArrowRight, + Down => Key::ArrowDown, Back => Key::Backspace, Return => Key::Enter, - // Space => Key::Space, Tab => Key::Tab, - LAlt | RAlt => Key::Alt, - LShift | RShift => Key::Shift, - LControl | RControl => Key::Control, - LWin | RWin => Key::Logo, + A => Key::A, + K => Key::K, + U => Key::U, + W => Key::W, + Z => Key::Z, _ => { return None; diff --git a/egui_web/src/backend.rs b/egui_web/src/backend.rs index 8c8b1bb10..bc76346d6 100644 --- a/egui_web/src/backend.rs +++ b/egui_web/src/backend.rs @@ -92,16 +92,18 @@ impl egui::app::TextureAllocator for webgl::Painter { // ---------------------------------------------------------------------------- -// TODO: Just use RawInput? /// Data gathered between frames. /// Is translated to `egui::RawInput` at the start of each frame. #[derive(Default)] pub struct WebInput { + /// In native points (not same as Egui points) pub mouse_pos: Option, - pub mouse_down: bool, // TODO: which button + /// Is this a touch screen? If so, we ignore mouse events. pub is_touch: bool, + /// In native points (not same as Egui points) pub scroll_delta: egui::Vec2, - pub events: Vec, + + pub raw: egui::RawInput, } impl WebInput { @@ -111,13 +113,12 @@ impl WebInput { let scroll_delta = std::mem::take(&mut self.scroll_delta) * scale; let mouse_pos = self.mouse_pos.map(|mp| pos2(mp.x * scale, mp.y * scale)); egui::RawInput { - mouse_down: self.mouse_down, mouse_pos, scroll_delta, screen_size: screen_size_in_native_points().unwrap() * scale, pixels_per_point: Some(pixels_per_point), time: now_sec(), - events: std::mem::take(&mut self.events), + ..self.raw.take() } } } @@ -127,7 +128,7 @@ impl WebInput { pub struct AppRunner { pixels_per_point: f32, pub web_backend: WebBackend, - pub web_input: WebInput, + pub input: WebInput, pub app: Box, pub needs_repaint: bool, // TODO: move } @@ -138,7 +139,7 @@ impl AppRunner { Ok(Self { pixels_per_point: native_pixels_per_point(), web_backend, - web_input: Default::default(), + input: Default::default(), app, needs_repaint: true, // TODO: move }) @@ -151,7 +152,7 @@ impl AppRunner { pub fn logic(&mut self) -> Result<(egui::Output, egui::PaintJobs), JsValue> { resize_canvas_to_screen_size(self.web_backend.canvas_id()); - let raw_input = self.web_input.new_frame(self.pixels_per_point); + let raw_input = self.input.new_frame(self.pixels_per_point); self.web_backend.begin_frame(raw_input); let mut integration_context = egui::app::IntegrationContext { diff --git a/egui_web/src/lib.rs b/egui_web/src/lib.rs index 6d6fc5ca5..66df5aa7c 100644 --- a/egui_web/src/lib.rs +++ b/egui_web/src/lib.rs @@ -187,14 +187,38 @@ pub fn location_hash() -> Option { web_sys::window()?.location().hash().ok() } -/// Web sends all all keys as strings, so it is up to us to figure out if it is +/// Web sends all keys as strings, so it is up to us to figure out if it is /// a real text input or the name of a key. fn should_ignore_key(key: &str) -> bool { let is_function_key = key.starts_with('F') && key.len() > 1; is_function_key || matches!( key, - "CapsLock" | "ContextMenu" | "NumLock" | "Pause" | "ScrollLock" + "Alt" + | "ArrowDown" + | "ArrowLeft" + | "ArrowRight" + | "ArrowUp" + | "Backspace" + | "CapsLock" + | "ContextMenu" + | "Control" + | "Delete" + | "End" + | "Enter" + | "Esc" + | "Escape" + | "Help" + | "Home" + | "Insert" + | "Meta" + | "NumLock" + | "PageDown" + | "PageUp" + | "Pause" + | "ScrollLock" + | "Shift" + | "Tab" ) } @@ -202,24 +226,25 @@ fn should_ignore_key(key: &str) -> bool { /// a real text input or the name of a key. pub fn translate_key(key: &str) -> Option { match key { - "Alt" => Some(egui::Key::Alt), + "ArrowDown" => Some(egui::Key::ArrowDown), + "ArrowLeft" => Some(egui::Key::ArrowLeft), + "ArrowRight" => Some(egui::Key::ArrowRight), + "ArrowUp" => Some(egui::Key::ArrowUp), "Backspace" => Some(egui::Key::Backspace), - "Control" => Some(egui::Key::Control), "Delete" => Some(egui::Key::Delete), - "ArrowDown" => Some(egui::Key::Down), "End" => Some(egui::Key::End), + "Enter" => Some(egui::Key::Enter), "Esc" | "Escape" => Some(egui::Key::Escape), - "Home" => Some(egui::Key::Home), "Help" | "Insert" => Some(egui::Key::Insert), - "ArrowLeft" => Some(egui::Key::Left), - "Meta" => Some(egui::Key::Logo), + "Home" => Some(egui::Key::Home), "PageDown" => Some(egui::Key::PageDown), "PageUp" => Some(egui::Key::PageUp), - "Enter" => Some(egui::Key::Enter), - "ArrowRight" => Some(egui::Key::Right), - "Shift" => Some(egui::Key::Shift), "Tab" => Some(egui::Key::Tab), - "ArrowUp" => Some(egui::Key::Up), + "a" | "A" => Some(egui::Key::A), + "k" | "K" => Some(egui::Key::K), + "u" | "U" => Some(egui::Key::U), + "w" | "W" => Some(egui::Key::W), + "z" | "Z" => Some(egui::Key::Z), _ => None, } } @@ -267,19 +292,24 @@ fn install_document_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> { // https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/ return; } + let mut runner_lock = runner_ref.0.lock(); + let modifiers = modifiers_from_event(&event); + runner_lock.input.raw.modifiers = modifiers; + let key = event.key(); - if !should_ignore_key(&key) { - if let Some(key) = translate_key(&key) { - runner_lock - .web_input - .events - .push(egui::Event::Key { key, pressed: true }); - } else { - runner_lock.web_input.events.push(egui::Event::Text(key)); - } - runner_lock.needs_repaint = true; + + if let Some(key) = translate_key(&key) { + runner_lock.input.raw.events.push(egui::Event::Key { + key, + pressed: true, + modifiers, + }); } + if !modifiers.ctrl && !modifiers.command && !should_ignore_key(&key) { + runner_lock.input.raw.events.push(egui::Event::Text(key)); + } + runner_lock.needs_repaint = true; }) as Box); document.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref())?; closure.forget(); @@ -290,14 +320,16 @@ fn install_document_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> { let runner_ref = runner_ref.clone(); let closure = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| { let mut runner_lock = runner_ref.0.lock(); - let key = event.key(); - if let Some(key) = translate_key(&key) { - runner_lock.web_input.events.push(egui::Event::Key { + let modifiers = modifiers_from_event(&event); + runner_lock.input.raw.modifiers = modifiers; + if let Some(key) = translate_key(&event.key()) { + runner_lock.input.raw.events.push(egui::Event::Key { key, pressed: false, + modifiers, }); - runner_lock.needs_repaint = true; } + runner_lock.needs_repaint = true; }) as Box); document.add_event_listener_with_callback("keyup", closure.as_ref().unchecked_ref())?; closure.forget(); @@ -315,6 +347,22 @@ fn install_document_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> { Ok(()) } +fn modifiers_from_event(event: &web_sys::KeyboardEvent) -> egui::Modifiers { + egui::Modifiers { + alt: event.alt_key(), + ctrl: event.ctrl_key(), + shift: event.shift_key(), + + // Ideally we should know if we are running or mac or not, + // but this works good enough for now. + mac_cmd: event.meta_key(), + + // Ideally we should know if we are running or mac or not, + // but this works good enough for now. + command: event.ctrl_key() || event.meta_key(), + } +} + fn install_canvas_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> { use wasm_bindgen::JsCast; let canvas = canvas_element(runner_ref.0.lock().canvas_id()).unwrap(); @@ -324,10 +372,10 @@ fn install_canvas_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> { let runner_ref = runner_ref.clone(); let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| { let mut runner_lock = runner_ref.0.lock(); - if !runner_lock.web_input.is_touch { - runner_lock.web_input.mouse_pos = + if !runner_lock.input.is_touch { + runner_lock.input.mouse_pos = Some(pos_from_mouse_event(runner_lock.canvas_id(), &event)); - runner_lock.web_input.mouse_down = true; + runner_lock.input.raw.mouse_down = true; runner_lock.logic().unwrap(); // in case we get "mouseup" the same frame. TODO: handle via events instead runner_lock.needs_repaint = true; event.stop_propagation(); @@ -343,8 +391,8 @@ fn install_canvas_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> { let runner_ref = runner_ref.clone(); let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| { let mut runner_lock = runner_ref.0.lock(); - if !runner_lock.web_input.is_touch { - runner_lock.web_input.mouse_pos = + if !runner_lock.input.is_touch { + runner_lock.input.mouse_pos = Some(pos_from_mouse_event(runner_lock.canvas_id(), &event)); runner_lock.needs_repaint = true; event.stop_propagation(); @@ -360,10 +408,10 @@ fn install_canvas_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> { let runner_ref = runner_ref.clone(); let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| { let mut runner_lock = runner_ref.0.lock(); - if !runner_lock.web_input.is_touch { - runner_lock.web_input.mouse_pos = + if !runner_lock.input.is_touch { + runner_lock.input.mouse_pos = Some(pos_from_mouse_event(runner_lock.canvas_id(), &event)); - runner_lock.web_input.mouse_down = false; + runner_lock.input.raw.mouse_down = false; runner_lock.needs_repaint = true; event.stop_propagation(); event.prevent_default(); @@ -378,8 +426,8 @@ fn install_canvas_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> { let runner_ref = runner_ref.clone(); let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| { let mut runner_lock = runner_ref.0.lock(); - if !runner_lock.web_input.is_touch { - runner_lock.web_input.mouse_pos = None; + if !runner_lock.input.is_touch { + runner_lock.input.mouse_pos = None; runner_lock.needs_repaint = true; event.stop_propagation(); event.prevent_default(); @@ -394,9 +442,9 @@ fn install_canvas_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> { let runner_ref = runner_ref.clone(); let closure = Closure::wrap(Box::new(move |event: web_sys::TouchEvent| { let mut runner_lock = runner_ref.0.lock(); - runner_lock.web_input.is_touch = true; - runner_lock.web_input.mouse_pos = Some(pos_from_touch_event(&event)); - runner_lock.web_input.mouse_down = true; + runner_lock.input.is_touch = true; + runner_lock.input.mouse_pos = Some(pos_from_touch_event(&event)); + runner_lock.input.raw.mouse_down = true; runner_lock.needs_repaint = true; event.stop_propagation(); event.prevent_default(); @@ -410,8 +458,8 @@ fn install_canvas_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> { let runner_ref = runner_ref.clone(); let closure = Closure::wrap(Box::new(move |event: web_sys::TouchEvent| { let mut runner_lock = runner_ref.0.lock(); - runner_lock.web_input.is_touch = true; - runner_lock.web_input.mouse_pos = Some(pos_from_touch_event(&event)); + runner_lock.input.is_touch = true; + runner_lock.input.mouse_pos = Some(pos_from_touch_event(&event)); runner_lock.needs_repaint = true; event.stop_propagation(); event.prevent_default(); @@ -425,10 +473,10 @@ fn install_canvas_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> { let runner_ref = runner_ref.clone(); let closure = Closure::wrap(Box::new(move |event: web_sys::TouchEvent| { let mut runner_lock = runner_ref.0.lock(); - runner_lock.web_input.is_touch = true; - runner_lock.web_input.mouse_down = false; // First release mouse to click... + runner_lock.input.is_touch = true; + runner_lock.input.raw.mouse_down = false; // First release mouse to click... runner_lock.logic().unwrap(); // ...do the clicking... (TODO: handle via events instead) - runner_lock.web_input.mouse_pos = None; // ...remove hover effect + runner_lock.input.mouse_pos = None; // ...remove hover effect runner_lock.needs_repaint = true; event.stop_propagation(); event.prevent_default(); @@ -442,8 +490,8 @@ fn install_canvas_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> { let runner_ref = runner_ref.clone(); let closure = Closure::wrap(Box::new(move |event: web_sys::WheelEvent| { let mut runner_lock = runner_ref.0.lock(); - runner_lock.web_input.scroll_delta.x -= event.delta_x() as f32; - runner_lock.web_input.scroll_delta.y -= event.delta_y() as f32; + runner_lock.input.scroll_delta.x -= event.delta_x() as f32; + runner_lock.input.scroll_delta.y -= event.delta_y() as f32; runner_lock.needs_repaint = true; event.stop_propagation(); event.prevent_default();