Browse Source

Text selection (#43)

* Refactor, clarify and improve text layout

* Introduce meticulous cursors for text galleys

* Rename "row" to "line"

We now have "paragraphs" separated by \n,
which are word-wrapped onto one or more rows.

* Fix some edge cases for the cursor movement

* Add modifier keys and implement moving cursors one word at a time

* Remove unused cursor_blink_hz

* Galley: Return Rect when asking for cursor position

* Implement text selection

* fix: text selection when mouse goes out of the TextEdit area

* Support Cmd+A ^W ^U ^K and shift-click

* Create `mod util`

* Implement undo for TextEdit

* Move focus between text fields with tab and shift-tab

* Update CHANGELOG.md
pull/46/head
Emil Ernerfeldt 4 years ago
committed by GitHub
parent
commit
c57fba41ab
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 10
      CHANGELOG.md
  2. 66
      TODO.md
  3. 2
      egui/src/containers/window.rs
  4. 2
      egui/src/context.rs
  5. 2
      egui/src/demos/app.rs
  6. 68
      egui/src/input.rs
  7. 6
      egui/src/lib.rs
  8. 68
      egui/src/memory.rs
  9. 2
      egui/src/paint/command.rs
  10. 302
      egui/src/paint/font.rs
  11. 879
      egui/src/paint/galley.rs
  12. 4
      egui/src/paint/mod.rs
  13. 4
      egui/src/paint/stats.rs
  14. 10
      egui/src/paint/tessellator.rs
  15. 4
      egui/src/painter.rs
  16. 5
      egui/src/style.rs
  17. 0
      egui/src/util/cache.rs
  18. 0
      egui/src/util/history.rs
  19. 9
      egui/src/util/mod.rs
  20. 0
      egui/src/util/mutex.rs
  21. 172
      egui/src/util/undoer.rs
  22. 10
      egui/src/widgets/mod.rs
  23. 2
      egui/src/widgets/slider.rs
  24. 708
      egui/src/widgets/text_edit.rs
  25. 77
      egui_glium/src/lib.rs
  26. 17
      egui_web/src/backend.rs
  27. 140
      egui_web/src/lib.rs

10
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 🐛

66
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

2
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,
}

2
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());

2
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;
// ----------------------------------------------------------------------------

68
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<Event>,
}
@ -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<Event>,
}
@ -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");
}

6
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::*,
};

68
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<Id>,
/// 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<Id>,
/// 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;

2
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},

302
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<Line>,
// 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<f32>,
/// 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<f32> {
fn layout_single_row_fragment(&self, text: &str) -> Vec<f32> {
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<Line> {
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<Row> {
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
}
}

879
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<usize> 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<usize> 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<Row>,
// 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<f32>,
/// 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,
}
}
);
}
}

4
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},
};

4
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 {

10
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);
}

4
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,

5
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"));

0
egui/src/cache.rs → egui/src/util/cache.rs

0
egui/src/history.rs → egui/src/util/history.rs

9
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;

0
egui/src/mutex.rs → egui/src/util/mutex.rs

172
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<State> {
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<State>,
#[cfg_attr(feature = "serde", serde(skip))]
flux: Option<Flux<State>>,
}
impl<State> std::fmt::Debug for Undoer<State> {
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<State> {
start_time: f64,
latest_change_time: f64,
latest_state: State,
}
impl<State> Undoer<State>
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();
}
}
}
}
}
}
}
}
}

10
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);

2
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() {

708
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<usize>,
cursorp: Option<CursorPair>,
#[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<Cursor> {
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<CCursorPair> {
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<Item = char>, 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 == '_'
}

77
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<egui::Key> {
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;

17
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<egui::Pos2>,
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<egui::Event>,
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<dyn App>,
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 {

140
egui_web/src/lib.rs

@ -187,14 +187,38 @@ pub fn location_hash() -> Option<String> {
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<egui::Key> {
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<dyn FnMut(_)>);
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<dyn FnMut(_)>);
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();

Loading…
Cancel
Save