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
This commit is contained in:
Emil Ernerfeldt 2020-11-15 20:12:26 +01:00 committed by GitHub
commit c57fba41ab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 2043 additions and 530 deletions

View file

@ -8,14 +8,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Added ⭐ ### Added ⭐
* You can now check if a `TextEdit` lost keyboard focus with `response.lost_kb_focus`. * `TextEdit` improvements:
* Added `ui.text_edit_singleline` and `ui.text_edit_multiline`. * 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 🔧 ### Changed 🔧
* Pressing enter in a single-line `TextEdit` will now surrender keyboard focus for it. * 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. * 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. * 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 🐛 ### Fixed 🐛

66
TODO.md
View file

@ -4,8 +4,8 @@ TODO-list for the Egui project. If you looking for something to do, look here.
## Top priority ## Top priority
* Text input: text selection etc * Egui-web copy-paste
* Refactor graphics layers and areas so one don't have to register LayerId:s. * Egui-web fetch
## Other ## 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. * [ ] Tooltip widget: Something that looks like this: (?) :that shows text on hover.
* [ ] ui.info_button().on_hover_text("More info here"); * [ ] ui.info_button().on_hover_text("More info here");
* [ ] Allow adding multiple tooltips to the same widget, showing them all one after the other. * [ ] 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 * [ ] Vertical slider
* [/] Color picker * [/] Color picker
* [x] linear rgb <-> sRGB * [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 * [ ] Additive blending aware color picker
* [ ] Premultiplied alpha is a bit of a pain in the ass. Maybe rethink this a bit. * [ ] Premultiplied alpha is a bit of a pain in the ass. Maybe rethink this a bit.
* [ ] Hue wheel * [ ] Hue wheel
* Containers
* [ ] Scroll areas
* [x] Vertical scrolling
* [x] Scroll-wheel input
* [x] Drag background to scroll
* [x] Kinetic scrolling
* [ ] Horizontal scrolling
* Input * Input
* [x] Distinguish between clicks and drags * [x] Distinguish between clicks and drags
* [x] Double-click * [x] Double-click
* [x] Text * [x] Text
* [x] Modifier keys
* [ ] Support all mouse buttons * [ ] Support all mouse buttons
* [ ] Distinguish between touch input and mouse input * [ ] Distinguish between touch input and mouse input
* [ ] Get modifier keys
* [ ] Keyboard shortcuts
* [ ] Copy, paste, undo, ...
* Text * Text
* [/] Unicode * [/] Unicode
* [x] Shared mutable expanding texture map * [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 * [ ] Change text style/color and continue in same layout
* Menu bar (File, Edit, etc) * Menu bar (File, Edit, etc)
* [ ] Sub-menus * [ ] 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 * [ ] 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) * [ ] Only repaint when mouse is over a Egui window (or is pressed and there is an active widget)
## Backends ## Integrations
* [ ] 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 :)
### egui_web ### egui_web
* [x] Scroll input * [x] Scroll input
* [x] Change to resize cursor on hover * [x] Change to resize cursor on hover
* [x] Port most code to Rust * [x] Port most code to Rust
* [x] Read url fragment and redirect to a subpage (e.g. different examples apps)] * [x] Read url fragment and redirect to a subpage (e.g. different examples apps)]
* [ ] Copy/paste support
* [ ] Async HTTP requests * [ ] Async HTTP requests
* [ ] Fix WebGL colors/blending (try EXT_sRGB) * [ ] Fix WebGL colors/blending (try EXT_sRGB)
* [ ] Embeddability * [ ] 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 * Different Egui instances, same app
* Allows very nice web integration * 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 ## Modularity
* [x] `trait Widget` (`Label`, `Slider`, `Checkbox`, ...) * [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] Collapsing header region
* [x] Tooltip * [x] Tooltip
* [x] Movable/resizable windows * [x] Movable/resizable windows
* [x] Kinetic windows
* [x] Add support for clicking hyperlinks * [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 * Containers
* [x] Vertical slider * [x] Vertical slider
* [x] Resize any side and corner on windows * [x] Resize any side and corner on windows
* [x] Fix autoshrink * [x] Fix autoshrink
* [x] Automatic positioning of new windows * [x] Automatic positioning of new windows
* [x] Vertical scroll areas
* [x] Scroll-wheel input
* [x] Drag background to scroll
* [x] Kinetic scrolling
* Simple animations * Simple animations
* Clip rects * Clip rects
* [x] Separate Ui::clip_rect from Ui::rect * [x] Separate Ui::clip_rect from Ui::rect

View file

@ -592,7 +592,7 @@ fn paint_frame_interaction(
struct TitleBar { struct TitleBar {
title_label: Label, title_label: Label,
title_galley: font::Galley, title_galley: Galley,
title_rect: Rect, title_rect: Rect,
rect: Rect, rect: Rect,
} }

View file

@ -205,7 +205,7 @@ impl Context {
} }
fn begin_frame_mut(&mut self, new_raw_input: RawInput) { 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.input = std::mem::take(&mut self.input).begin_frame(new_raw_input);
*self.available_rect.lock() = Some(self.input.screen_rect()); *self.available_rect.lock() = Some(self.input.screen_rect());

View file

@ -1,4 +1,4 @@
use crate::{app, demos, Context, History, Ui}; use crate::{app, demos, util::History, Context, Ui};
use std::sync::Arc; use std::sync::Arc;
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------

View file

@ -1,6 +1,6 @@
//! The input needed by Egui. //! 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) /// If mouse moves more than this, it is no longer a click (but maybe a drag)
const MAX_CLICK_DIST: f32 = 6.0; const MAX_CLICK_DIST: f32 = 6.0;
@ -33,6 +33,9 @@ pub struct RawInput {
/// Time in seconds. Relative to whatever. Used for animations. /// Time in seconds. Relative to whatever. Used for animations.
pub time: f64, pub time: f64,
/// Which modifier keys are down at the start of the frame?
pub modifiers: Modifiers,
/// In-order events received this frame /// In-order events received this frame
pub events: Vec<Event>, pub events: Vec<Event>,
} }
@ -47,6 +50,7 @@ impl RawInput {
screen_size: self.screen_size, screen_size: self.screen_size,
pixels_per_point: self.pixels_per_point, pixels_per_point: self.pixels_per_point,
time: self.time, time: self.time,
modifiers: self.modifiers,
events: std::mem::take(&mut self.events), 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. /// Should be set to the expected time between frames when painting at vsync speeds.
pub predicted_dt: f32, pub predicted_dt: f32,
/// Which modifier keys are down at the start of the frame?
pub modifiers: Modifiers,
/// In-order events received this frame /// In-order events received this frame
pub events: Vec<Event>, pub events: Vec<Event>,
} }
@ -151,7 +158,7 @@ impl Default for MouseInput {
} }
/// An input event. Only covers events used by Egui. /// An input event. Only covers events used by Egui.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum Event { pub enum Event {
Copy, Copy,
Cut, Cut,
@ -161,33 +168,51 @@ pub enum Event {
Key { Key {
key: Key, key: Key,
pressed: bool, 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)] #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum Key { pub enum Key {
Alt, ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
Backspace, Backspace,
Control,
Delete, Delete,
Down,
End, End,
Enter,
Escape, Escape,
Home, Home,
Insert, Insert,
Left,
/// Windows key or Mac Command key
Logo,
PageDown, PageDown,
PageUp, PageUp,
/// Enter/Return key
Enter,
Right,
Shift,
// Space,
Tab, 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 { impl InputState {
@ -202,7 +227,8 @@ impl InputState {
pixels_per_point: new.pixels_per_point.or(self.pixels_per_point), pixels_per_point: new.pixels_per_point.or(self.pixels_per_point),
time: new.time, time: new.time,
unstable_dt, 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 events: new.events.clone(), // TODO: remove clone() and use raw.events
raw: new, raw: new,
} }
@ -227,7 +253,8 @@ impl InputState {
event, event,
Event::Key { Event::Key {
key, key,
pressed: true pressed: true,
..
} if *key == desired_key } if *key == desired_key
) )
}) })
@ -240,7 +267,8 @@ impl InputState {
event, event,
Event::Key { Event::Key {
key, key,
pressed: false pressed: false,
..
} if *key == desired_key } if *key == desired_key
) )
}) })
@ -343,6 +371,7 @@ impl RawInput {
screen_size, screen_size,
pixels_per_point, pixels_per_point,
time, time,
modifiers,
events, events,
} = self; } = self;
@ -357,6 +386,7 @@ impl RawInput {
"Also called HDPI factor.\nNumber of physical pixels per each logical pixel.", "Also called HDPI factor.\nNumber of physical pixels per each logical pixel.",
); );
ui.label(format!("time: {:.3} s", time)); ui.label(format!("time: {:.3} s", time));
ui.label(format!("modifiers: {:#?}", modifiers));
ui.label(format!("events: {:?}", events)) ui.label(format!("events: {:?}", events))
.on_hover_text("key presses etc"); .on_hover_text("key presses etc");
} }
@ -373,6 +403,7 @@ impl InputState {
time, time,
unstable_dt, unstable_dt,
predicted_dt, predicted_dt,
modifiers,
events, events,
} = self; } = self;
@ -397,6 +428,7 @@ impl InputState {
1e3 * unstable_dt 1e3 * unstable_dt
)); ));
ui.label(format!("expected dt: {:.1} ms", 1e3 * predicted_dt)); ui.label(format!("expected dt: {:.1} ms", 1e3 * predicted_dt));
ui.label(format!("modifiers: {:#?}", modifiers));
ui.label(format!("events: {:?}", events)) ui.label(format!("events: {:?}", events))
.on_hover_text("key presses etc"); .on_hover_text("key presses etc");
} }

View file

@ -60,11 +60,9 @@
pub mod align; pub mod align;
mod animation_manager; mod animation_manager;
pub mod app; pub mod app;
pub(crate) mod cache;
pub mod containers; pub mod containers;
mod context; mod context;
pub mod demos; pub mod demos;
mod history;
mod id; mod id;
mod input; mod input;
mod introspection; mod introspection;
@ -73,12 +71,12 @@ mod layout;
pub mod math; pub mod math;
mod memory; mod memory;
pub mod menu; pub mod menu;
pub mod mutex;
pub mod paint; pub mod paint;
mod painter; mod painter;
mod style; mod style;
mod types; mod types;
mod ui; mod ui;
pub mod util;
pub mod widgets; pub mod widgets;
pub use { pub use {
@ -86,7 +84,6 @@ pub use {
containers::*, containers::*,
context::Context, context::Context,
demos::DemoApp, demos::DemoApp,
history::History,
id::Id, id::Id,
input::*, input::*,
layers::*, layers::*,
@ -101,6 +98,7 @@ pub use {
style::Style, style::Style,
types::*, types::*,
ui::Ui, ui::Ui,
util::mutex,
widgets::*, widgets::*,
}; };

View file

@ -1,11 +1,10 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use crate::{ use crate::{
area, area, collapsing_header, menu,
cache::Cache,
collapsing_header, menu,
paint::color::{Hsva, Srgba}, paint::color::{Hsva, Srgba},
resize, scroll_area, resize, scroll_area,
util::Cache,
widgets::text_edit, widgets::text_edit,
window, Id, LayerId, Pos2, Rect, window, Id, LayerId, Pos2, Rect,
}; };
@ -84,6 +83,18 @@ pub(crate) struct Interaction {
/// What had keyboard focus previous frame? /// What had keyboard focus previous frame?
pub kb_focus_id_previous_frame: Option<Id>, 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. /// HACK: windows have low priority on dragging.
/// This is so that if you drag a slider in a window, /// This is so that if you drag a slider in a window,
/// the slider will steal the drag away from the 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() 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.kb_focus_id_previous_frame = self.kb_focus_id;
self.click_interest = false; self.click_interest = false;
self.drag_interest = false; self.drag_interest = false;
@ -119,13 +134,34 @@ impl Interaction {
self.click_id = None; self.click_id = None;
self.drag_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 { 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.used_ids.clear();
self.interaction.begin_frame(prev_input); self.interaction.begin_frame(prev_input, new_input);
if !prev_input.mouse.down { if !prev_input.mouse.down {
self.window_interaction = None; 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). /// Stop editing of active `TextEdit` (if any).
pub fn stop_text_input(&mut self) { pub fn stop_text_input(&mut self) {
self.interaction.kb_focus_id = None; self.interaction.kb_focus_id = None;

View file

@ -1,5 +1,5 @@
use { use {
super::{font::Galley, fonts::TextStyle, Fonts, Srgba, Triangles}, super::{fonts::TextStyle, Fonts, Galley, Srgba, Triangles},
crate::{ crate::{
align::{anchor_rect, Align}, align::{anchor_rect, Align},
math::{Pos2, Rect}, math::{Pos2, Rect},

View file

@ -9,145 +9,11 @@ use {
use crate::{ use crate::{
math::{vec2, Vec2}, math::{vec2, Vec2},
mutex::Mutex, mutex::Mutex,
paint::{Galley, Row},
}; };
use super::texture_atlas::TextureAtlas; 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. // 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 (point * self.pixels_per_point).round() / self.pixels_per_point
} }
/// Height of one line of text. In points /// Height of one row of text. In points
/// TODO: rename height ? pub fn row_height(&self) -> f32 {
pub fn line_spacing(&self) -> f32 {
self.scale_in_pixels / self.pixels_per_point
}
pub fn height(&self) -> f32 {
self.scale_in_pixels / self.pixels_per_point 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) 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 { 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) { if let Some(glyph_info) = self.glyph_infos.read().get(&c) {
return *glyph_info; return *glyph_info;
@ -283,22 +141,22 @@ impl Font {
glyph_info glyph_info
} }
/// Typeset the given text onto one line. /// Typeset the given text onto one row.
/// Assumes there are no \n in the text. /// Any `\n` will show up as `REPLACEMENT_CHAR` ('?').
/// Always returns exactly one fragment. /// Always returns exactly one `Row` in the `Galley`.
pub fn layout_single_line(&self, text: String) -> Galley { pub fn layout_single_line(&self, text: String) -> Galley {
let x_offsets = self.layout_single_line_fragment(&text); let x_offsets = self.layout_single_row_fragment(&text);
let line = Line { let row = Row {
x_offsets, x_offsets,
y_min: 0.0, y_min: 0.0,
y_max: self.height(), y_max: self.row_height(),
ends_with_newline: false, ends_with_newline: false,
}; };
let width = line.max_x(); let width = row.max_x();
let size = vec2(width, self.height()); let size = vec2(width, self.row_height());
let galley = Galley { let galley = Galley {
text, text,
lines: vec![line], rows: vec![row],
size, size,
}; };
galley.sanity_check(); galley.sanity_check();
@ -306,61 +164,61 @@ impl Font {
} }
pub fn layout_multiline(&self, text: String, max_width_in_points: f32) -> Galley { 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 cursor_y = 0.0;
let mut lines = Vec::new(); let mut rows = Vec::new();
let mut paragraph_start = 0; let mut paragraph_start = 0;
while paragraph_start < text.len() { while paragraph_start < text.len() {
let next_newline = text[paragraph_start..].find('\n'); let next_newline = text[paragraph_start..].find('\n');
let paragraph_end = next_newline let paragraph_end = next_newline
.map(|newline| paragraph_start + newline + 1) .map(|newline| paragraph_start + newline)
.unwrap_or_else(|| text.len()); .unwrap_or_else(|| text.len());
assert!(paragraph_start < paragraph_end); assert!(paragraph_start <= paragraph_end);
let paragraph_text = &text[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); 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 { for row in &mut paragraph_rows {
line.y_min += cursor_y; row.y_min += cursor_y;
line.y_max += cursor_y; row.y_max += cursor_y;
} }
cursor_y = paragraph_lines.last().unwrap().y_max; cursor_y = paragraph_rows.last().unwrap().y_max;
cursor_y += line_spacing * 0.4; // extra spacing between paragraphs. less hacky 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') { if text.is_empty() || text.ends_with('\n') {
// Add an empty last line for correct visuals etc: rows.push(Row {
lines.push(Line {
x_offsets: vec![0.0], x_offsets: vec![0.0],
y_min: cursor_y, y_min: cursor_y,
y_max: cursor_y + line_spacing, y_max: cursor_y + row_height,
ends_with_newline: text.ends_with('\n'), ends_with_newline: false,
}); });
} }
let mut widest_line = 0.0; let mut widest_row = 0.0;
for line in &lines { for row in &rows {
widest_line = line.max_x().max(widest_line); 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.sanity_check();
galley galley
} }
/// Typeset the given text onto one line. /// Typeset the given text onto one row.
/// Assumes there are no \n in the text. /// Assumes there are no `\n` in the text.
/// Return `x_offsets`, one longer than the number of characters 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 scale_in_pixels = Scale::uniform(self.scale_in_pixels);
let mut x_offsets = Vec::with_capacity(text.chars().count() + 1); 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. /// A paragraph is text with no line break character in it.
/// The text will be linebreaked by the given `max_width_in_points`. /// The text will be wrapped by the given `max_width_in_points`.
pub fn layout_paragraph_max_width(&self, text: &str, max_width_in_points: f32) -> Vec<Line> { fn layout_paragraph_max_width(&self, text: &str, max_width_in_points: f32) -> Vec<Row> {
let full_x_offsets = self.layout_single_line_fragment(text); 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)] #![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 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 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() { 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 { if let Some(last_space_idx) = last_space {
let include_trailing_space = true; let include_trailing_space = true;
let line = if include_trailing_space { let row = if include_trailing_space {
Line { Row {
x_offsets: full_x_offsets[line_start_idx..=last_space_idx + 1] x_offsets: full_x_offsets[row_start_idx..=last_space_idx + 1]
.iter() .iter()
.map(|x| x - line_start_x) .map(|x| x - row_start_x)
.collect(), .collect(),
y_min: cursor_y, y_min: cursor_y,
y_max: cursor_y + self.height(), y_max: cursor_y + self.row_height(),
ends_with_newline: false, // we'll fix this later ends_with_newline: false,
} }
} else { } else {
Line { Row {
x_offsets: full_x_offsets[line_start_idx..=last_space_idx] x_offsets: full_x_offsets[row_start_idx..=last_space_idx]
.iter() .iter()
.map(|x| x - line_start_x) .map(|x| x - row_start_x)
.collect(), .collect(),
y_min: cursor_y, y_min: cursor_y,
y_max: cursor_y + self.height(), y_max: cursor_y + self.row_height(),
ends_with_newline: false, // we'll fix this later ends_with_newline: false,
} }
}; };
line.sanity_check(); row.sanity_check();
out_lines.push(line); out_rows.push(row);
line_start_idx = last_space_idx + 1; row_start_idx = last_space_idx + 1;
line_start_x = full_x_offsets[line_start_idx]; row_start_x = full_x_offsets[row_start_idx];
last_space = None; last_space = None;
cursor_y += self.line_spacing(); cursor_y += self.row_height();
cursor_y = self.round_to_pixel(cursor_y); cursor_y = self.round_to_pixel(cursor_y);
} }
} }
@ -452,25 +320,21 @@ impl Font {
} }
} }
if line_start_idx + 1 < full_x_offsets.len() { if row_start_idx + 1 < full_x_offsets.len() {
let line = Line { let row = Row {
x_offsets: full_x_offsets[line_start_idx..] x_offsets: full_x_offsets[row_start_idx..]
.iter() .iter()
.map(|x| x - line_start_x) .map(|x| x - row_start_x)
.collect(), .collect(),
y_min: cursor_y, y_min: cursor_y,
y_max: cursor_y + self.height(), y_max: cursor_y + self.row_height(),
ends_with_newline: false, // we'll fix this later ends_with_newline: false,
}; };
line.sanity_check(); row.sanity_check();
out_lines.push(line); out_rows.push(row);
} }
if text.ends_with('\n') { out_rows
out_lines.last_mut().unwrap().ends_with_newline = true;
}
out_lines
} }
} }

879
egui/src/paint/galley.rs Normal file
View file

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

View file

@ -6,6 +6,7 @@ pub mod color;
pub mod command; pub mod command;
pub mod font; pub mod font;
pub mod fonts; pub mod fonts;
mod galley;
pub mod stats; pub mod stats;
pub mod tessellator; pub mod tessellator;
mod texture_atlas; mod texture_atlas;
@ -14,9 +15,10 @@ pub use {
color::{Rgba, Srgba}, color::{Rgba, Srgba},
command::{PaintCmd, Stroke}, command::{PaintCmd, Stroke},
fonts::{FontDefinitions, FontFamily, Fonts, TextStyle}, fonts::{FontDefinitions, FontFamily, Fonts, TextStyle},
galley::*,
stats::PaintStats, stats::PaintStats,
tessellator::{ tessellator::{
PaintJob, PaintJobs, TesselationOptions, TextureId, Triangles, Vertex, WHITE_UV, PaintJob, PaintJobs, TesselationOptions, TextureId, Triangles, Vertex, WHITE_UV,
}, },
texture_atlas::Texture, texture_atlas::{Texture, TextureAtlas},
}; };

View file

@ -66,8 +66,8 @@ impl AllocInfo {
} }
} }
pub fn from_galley(galley: &font::Galley) -> Self { pub fn from_galley(galley: &Galley) -> Self {
Self::from_slice(galley.text.as_bytes()) + Self::from_slice(&galley.lines) Self::from_slice(galley.text.as_bytes()) + Self::from_slice(&galley.rows)
} }
pub fn from_triangles(triangles: &Triangles) -> Self { pub fn from_triangles(triangles: &Triangles) -> Self {

View file

@ -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 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 font = &fonts[text_style];
let mut chars = galley.text.chars(); 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_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 = let is_line_visible =
line_max_y >= clip_rect.min.y && line_min_y <= clip_rect.max.y; 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); 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); assert_eq!(chars.next(), None);
} }

View file

@ -5,7 +5,7 @@ use crate::{
color, color,
layers::PaintCmdIdx, layers::PaintCmdIdx,
math::{Pos2, Rect, Vec2}, math::{Pos2, Rect, Vec2},
paint::{font, Fonts, PaintCmd, Stroke, TextStyle}, paint::{Fonts, Galley, PaintCmd, Stroke, TextStyle},
Context, LayerId, Srgba, Context, LayerId, Srgba,
}; };
@ -278,7 +278,7 @@ impl Painter {
} }
/// Paint text that has already been layed out in a `Galley`. /// 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 { self.add(PaintCmd::Text {
pos, pos,
galley, galley,

View file

@ -131,8 +131,6 @@ pub struct Visuals {
pub resize_corner_size: f32, 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, pub text_cursor_width: f32,
/// Allow child widgets to be just on the border and still have a stroke with some thickness /// 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), dark_bg_color: Srgba::black_alpha(140),
window_corner_radius: 10.0, window_corner_radius: 10.0,
resize_corner_size: 12.0, resize_corner_size: 12.0,
cursor_blink_hz: 0.0, // 1.0 looks good
text_cursor_width: 2.0, text_cursor_width: 2.0,
clip_rect_margin: 3.0, clip_rect_margin: 3.0,
debug_widget_rects: false, debug_widget_rects: false,
@ -444,7 +441,6 @@ impl Visuals {
dark_bg_color, dark_bg_color,
window_corner_radius, window_corner_radius,
resize_corner_size, resize_corner_size,
cursor_blink_hz,
text_cursor_width, text_cursor_width,
clip_rect_margin, clip_rect_margin,
debug_widget_rects, debug_widget_rects,
@ -455,7 +451,6 @@ impl Visuals {
ui_color(ui, dark_bg_color, "dark_bg_color"); 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(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(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(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")); ui.add(Slider::f32(clip_rect_margin, 0.0..=20.0).text("clip_rect_margin"));

9
egui/src/util/mod.rs Normal file
View file

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

172
egui/src/util/undoer.rs Normal file
View file

@ -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();
}
}
}
}
}
}
}
}
}

View file

@ -78,7 +78,7 @@ impl Label {
self self
} }
pub fn layout(&self, ui: &Ui) -> font::Galley { pub fn layout(&self, ui: &Ui) -> Galley {
let max_width = ui.available().width(); let max_width = ui.available().width();
// Prevent word-wrapping after a single letter, and other silly shit: // Prevent word-wrapping after a single letter, and other silly shit:
// TODO: general "don't force labels and similar to wrap so early" // TODO: general "don't force labels and similar to wrap so early"
@ -86,7 +86,7 @@ impl Label {
self.layout_width(ui, max_width) 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 text_style = self.text_style_or_default(ui.style());
let font = &ui.fonts()[text_style]; let font = &ui.fonts()[text_style];
if self.multiline { if self.multiline {
@ -98,7 +98,7 @@ impl Label {
pub fn font_height(&self, fonts: &Fonts, style: &Style) -> f32 { pub fn font_height(&self, fonts: &Fonts, style: &Style) -> f32 {
let text_style = self.text_style_or_default(style); 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. // 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. // TODO: a paint method for painting anywhere in a ui.
// This should be the easiest method of putting text anywhere. // 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_style = self.text_style_or_default(ui.style());
let text_color = self let text_color = self
.text_color .text_color
@ -222,7 +222,7 @@ impl Widget for Hyperlink {
if response.hovered { if response.hovered {
// Underline: // Underline:
for line in &galley.lines { for line in &galley.rows {
let pos = response.rect.min; let pos = response.rect.min;
let y = pos.y + line.y_max; let y = pos.y + line.y_max;
let y = ui.painter().round_to_pixel(y); let y = ui.painter().round_to_pixel(y);

View file

@ -367,7 +367,7 @@ impl<'a> Widget for Slider<'a> {
let text_style = TextStyle::Button; let text_style = TextStyle::Button;
let font = &ui.fonts()[text_style]; let font = &ui.fonts()[text_style];
let height = font let height = font
.line_spacing() .row_height()
.at_least(ui.style().spacing.interact_size.y); .at_least(ui.style().spacing.interact_size.y);
if self.text.is_some() { if self.text.is_some() {

View file

@ -1,11 +1,99 @@
use crate::{paint::*, *}; use crate::{paint::*, util::undoer::Undoer, *};
#[derive(Clone, Debug, Default)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub(crate) struct State {
cursorp: Option<CursorPair>,
#[cfg_attr(feature = "serde", serde(skip))]
undoer: Undoer<(CCursorPair, String)>,
}
#[derive(Clone, Copy, Debug, Default)] #[derive(Clone, Copy, Debug, Default)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub(crate) struct State { struct CursorPair {
/// Character based, NOT bytes. /// When selecting with a mouse, this is where the mouse was released.
/// TODO: store as line + row /// When moving with e.g. shift+arrows, this is what moves.
pub cursor: Option<usize>, /// 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. /// 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 text_style = text_style.unwrap_or_else(|| ui.style().body_text_style);
let font = &ui.fonts()[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 available_width = ui.available().width();
let mut galley = if multiline { let mut galley = if multiline {
font.layout_multiline(text.clone(), available_width) font.layout_multiline(text.clone(), available_width)
@ -162,17 +250,59 @@ impl<'t> Widget for TextEdit<'t> {
} else { } else {
Sense::nothing() Sense::nothing()
}; };
let response = ui.interact(rect, id, sense); // TODO: implement drag-select let response = ui.interact(rect, id, sense);
if response.clicked && enabled { if enabled {
ui.memory().request_kb_focus(id); ui.memory().interested_in_kb_focus(id);
}
if enabled {
if let Some(mouse_pos) = ui.input().mouse.pos { 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 // User clicked somewhere else
ui.memory().surrender_kb_focus(id); ui.memory().surrender_kb_focus(id);
} }
if !enabled { if !enabled {
ui.memory().surrender_kb_focus(id); ui.memory().surrender_kb_focus(id);
} }
@ -182,27 +312,70 @@ impl<'t> Widget for TextEdit<'t> {
} }
if ui.memory().has_kb_focus(id) && enabled { if ui.memory().has_kb_focus(id) && enabled {
let mut cursor = state.cursor.unwrap_or_else(|| text.chars().count()); let mut cursorp = state
cursor = clamp(cursor, 0..=text.chars().count()); .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 { for event in &ui.input().events {
match event { let did_mutate_text = match event {
Event::Copy | Event::Cut => { Event::Copy => {
// TODO: cut if cursorp.is_empty() {
ui.ctx().output().copied_text = text.clone(); 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) => { Event::Text(text_to_insert) => {
// newlines are handled by `Key::Enter`. // Newlines are handled by `Key::Enter`.
if text_to_insert != "\n" && text_to_insert != "\r" { if !text_to_insert.is_empty()
insert_text(&mut cursor, text, text_to_insert); && 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 { Event::Key {
key: Key::Enter, key: Key::Enter,
pressed: true, pressed: true,
..
} => { } => {
if multiline { 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 { } else {
// Common to end input with enter // Common to end input with enter
ui.memory().surrender_kb_focus(id); ui.memory().surrender_kb_focus(id);
@ -212,35 +385,64 @@ impl<'t> Widget for TextEdit<'t> {
Event::Key { Event::Key {
key: Key::Escape, key: Key::Escape,
pressed: true, pressed: true,
..
} => { } => {
ui.memory().surrender_kb_focus(id); ui.memory().surrender_kb_focus(id);
break; 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: state
let font = &ui.fonts()[text_style]; .undoer
galley = if multiline { .feed_state(ui.input().time, &(cursorp.as_ccursorp(), text.clone()));
font.layout_multiline(text.clone(), available_width)
} else {
font.layout_single_line(text.clone())
};
// dbg!(&galley);
} }
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 let bg_rect = response.rect.expand(2.0); // breathing room for content
painter.add(PaintCmd::Rect { ui.painter().add(PaintCmd::Rect {
rect: bg_rect, rect: bg_rect,
corner_radius: visuals.corner_radius, corner_radius: visuals.corner_radius,
fill: ui.style().visuals.dark_bg_color, fill: ui.style().visuals.dark_bg_color,
@ -250,29 +452,22 @@ impl<'t> Widget for TextEdit<'t> {
} }
if ui.memory().has_kb_focus(id) { if ui.memory().has_kb_focus(id) {
let cursor_blink_hz = ui.style().visuals.cursor_blink_hz; if let Some(cursorp) = state.cursorp {
let show_cursor = if 0.0 < cursor_blink_hz { // TODO: color from Style
ui.ctx().request_repaint(); // TODO: only when cursor blinks on or off let selection_color = Rgba::new(0.0, 0.5, 1.0, 0.0).multiply(0.15).into(); // additive!
(ui.input().time * cursor_blink_hz as f64 * 3.0).floor() as i64 % 3 != 0 let end_color = Rgba::new(0.3, 0.6, 1.0, 1.0).into();
} else { paint_cursor_selection(ui, response.rect.min, &galley, &cursorp, selection_color);
true paint_cursor_end(ui, response.rect.min, &galley, &cursorp.primary, end_color);
};
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),
);
}
} }
} }
let text_color = text_color let text_color = text_color
.or(ui.style().visuals.override_text_color) .or(ui.style().visuals.override_text_color)
.unwrap_or_else(|| visuals.text_color()); // .unwrap_or_else(|| ui.style().interact(&response).text_color()); // too bright
painter.galley(response.rect.min, galley, text_style, text_color); .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); ui.memory().text_edit.insert(id, state);
Response { 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 char_it = text.chars();
let mut new_text = String::with_capacity(text.capacity()); let mut new_text = String::with_capacity(text.len() + text_to_insert.len());
for _ in 0..*cursor { for _ in 0..ccursor.index {
let c = char_it.next().unwrap(); let c = char_it.next().unwrap();
new_text.push(c); new_text.push(c);
} }
*cursor += text_to_insert.chars().count(); ccursor.index += text_to_insert.chars().count();
new_text += text_to_insert; new_text += text_to_insert;
new_text.extend(char_it); new_text.extend(char_it);
*text = new_text; *text = new_text;
} }
fn on_key_press(cursor: &mut usize, text: &mut String, key: Key) { // ----------------------------------------------------------------------------
// eprintln!("on_key_press before: '{}', cursor at {}", text, cursor);
fn delete_selected(text: &mut String, cursorp: &CursorPair) -> CCursor {
let [min, max] = cursorp.sorted();
delete_selected_ccursor_range(text, [min.ccursor, max.ccursor])
}
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())
}
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 { match key {
Key::Backspace if *cursor > 0 => { Key::Backspace => {
*cursor -= 1; let ccursor = if modifiers.mac_cmd {
delete_paragraph_before_cursor(text, galley, cursorp)
let mut char_it = text.chars(); } else if let Some(cursor) = cursorp.single() {
let mut new_text = String::with_capacity(text.capacity()); if modifiers.alt || modifiers.ctrl {
for _ in 0..*cursor { // alt on mac, ctrl on windows
new_text.push(char_it.next().unwrap()) delete_previous_word(text, cursor.ccursor)
} } else {
new_text.extend(char_it.skip(1)); delete_previous_char(text, cursor.ccursor)
*text = new_text; }
} else {
delete_selected(text, cursorp)
};
Some(CCursorPair::one(ccursor))
} }
Key::Delete => { Key::Delete => {
let mut char_it = text.chars(); let ccursor = if modifiers.mac_cmd {
let mut new_text = String::with_capacity(text.capacity()); delete_paragraph_after_cursor(text, galley, cursorp)
for _ in 0..*cursor { } else if let Some(cursor) = cursorp.single() {
new_text.push(char_it.next().unwrap()) if modifiers.alt || modifiers.ctrl {
} // alt on mac, ctrl on windows
new_text.extend(char_it.skip(1)); delete_next_word(text, cursor.ccursor)
*text = new_text; } 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::Enter => {} // handled earlier
Key::A if modifiers.command => {
// select all
*cursorp = CursorPair::two(Cursor::default(), galley.end());
None
}
Key::K if modifiers.ctrl => {
let ccursor = delete_paragraph_after_cursor(text, galley, cursorp);
Some(CCursorPair::one(ccursor))
}
Key::U if modifiers.ctrl => {
let ccursor = delete_paragraph_before_cursor(text, galley, cursorp);
Some(CCursorPair::one(ccursor))
}
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::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
}
_ => None,
}
}
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);
}
}
Key::Home => { Key::Home => {
// To start of paragraph: if modifiers.ctrl {
let pos = line_col_from_char_idx(text, *cursor); // windows behavior
*cursor = char_idx_from_line_col(text, (pos.0, 0)); *cursor = Cursor::default();
} else {
*cursor = galley.cursor_begin_of_row(cursor);
}
} }
Key::End => { Key::End => {
// To end of paragraph: if modifiers.ctrl {
let pos = line_col_from_char_idx(text, *cursor); // windows behavior
let line = line_from_number(text, pos.0); *cursor = galley.end();
*cursor = char_idx_from_line_col(text, (pos.0, line.chars().count())); } else {
*cursor = galley.cursor_end_of_row(cursor);
}
} }
Key::Left if *cursor > 0 => {
*cursor -= 1;
}
Key::Right => {
*cursor = (*cursor + 1).min(text.chars().count());
}
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::Down => {
let mut pos = line_col_from_char_idx(text, *cursor);
pos.0 += 1;
*cursor = char_idx_from_line_col(text, pos);
}
_ => {}
}
// eprintln!("on_key_press after: '{}', cursor at {}\n", text, cursor); _ => unreachable!(),
}
} }
fn line_col_from_char_idx(s: &str, char_idx: usize) -> (usize, usize) { // ----------------------------------------------------------------------------
let mut char_count = 0;
let mut last_line_nr = 0; fn ccursor_next_word(text: &str, ccursor: CCursor) -> CCursor {
let mut last_line = s; CCursor {
for (line_nr, line) in s.split('\n').enumerate() { index: next_word_char_index(text.chars(), ccursor.index),
let line_width = line.chars().count(); prefer_next_row: false,
if char_idx <= char_count + line_width {
return (line_nr, char_idx - char_count);
}
char_count += line_width + 1;
last_line_nr = line_nr;
last_line = line;
} }
// safe fallback:
(last_line_nr, last_line.chars().count())
} }
fn char_idx_from_line_col(s: &str, pos: (usize, usize)) -> usize { fn ccursor_previous_word(text: &str, ccursor: CCursor) -> CCursor {
let mut char_count = 0; let num_chars = text.chars().count();
for (line_nr, line) in s.split('\n').enumerate() { CCursor {
if line_nr == pos.0 { index: num_chars - next_word_char_index(text.chars().rev(), num_chars - ccursor.index),
return char_count + pos.1.min(line.chars().count()); prefer_next_row: true,
}
char_count += line.chars().count() + 1;
} }
char_count
} }
fn line_from_number(s: &str, desired_line_number: usize) -> &str { fn next_word_char_index(it: impl Iterator<Item = char>, mut index: usize) -> usize {
for (line_nr, line) in s.split('\n').enumerate() { let mut it = it.skip(index);
if line_nr == desired_line_number { if let Some(_first) = it.next() {
return line; 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 == '_'
} }

View file

@ -20,10 +20,6 @@ pub use clipboard::ClipboardContext; // TODO: remove
pub struct GliumInputState { pub struct GliumInputState {
raw: egui::RawInput, raw: egui::RawInput,
/// Command modifier key.
/// Mac command key on Mac, ctrl on Window/Linux.
cmd: bool,
} }
impl GliumInputState { impl GliumInputState {
@ -33,7 +29,6 @@ impl GliumInputState {
pixels_per_point: Some(pixels_per_point), pixels_per_point: Some(pixels_per_point),
..Default::default() ..Default::default()
}, },
cmd: false,
} }
} }
} }
@ -63,40 +58,51 @@ pub fn input_to_egui(
input_state.raw.mouse_pos = None; input_state.raw.mouse_pos = None;
} }
ReceivedCharacter(ch) => { 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())); input_state.raw.events.push(Event::Text(ch.to_string()));
} }
} }
KeyboardInput { input, .. } => { KeyboardInput { input, .. } => {
if let Some(virtual_keycode) = input.virtual_keycode { if let Some(keycode) = input.virtual_keycode {
let is_command_key = if cfg!(target_os = "macos") { let pressed = input.state == glutin::event::ElementState::Pressed;
matches!(virtual_keycode, VirtualKeyCode::LWin | VirtualKeyCode::RWin)
} else {
matches!(
virtual_keycode,
VirtualKeyCode::LControl | VirtualKeyCode::RControl
)
};
if is_command_key { if matches!(keycode, VirtualKeyCode::LAlt | VirtualKeyCode::RAlt) {
input_state.cmd = input.state == glutin::event::ElementState::Pressed; 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") if cfg!(target_os = "macos")
&& input_state.cmd && input_state.raw.modifiers.mac_cmd
&& virtual_keycode == VirtualKeyCode::Q && keycode == VirtualKeyCode::Q
{ {
*control_flow = ControlFlow::Exit; *control_flow = ControlFlow::Exit;
} }
// VirtualKeyCode::Paste etc in winit are broken/untrustworthy, // VirtualKeyCode::Paste etc in winit are broken/untrustworthy,
// so we detect these things manually: // 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); 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); 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 { if let Some(clipboard) = clipboard {
match clipboard.get_contents() { match clipboard.get_contents() {
Ok(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 { input_state.raw.events.push(Event::Key {
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, End => Key::End,
PageDown => Key::PageDown, PageDown => Key::PageDown,
PageUp => Key::PageUp, PageUp => Key::PageUp,
Left => Key::Left, Left => Key::ArrowLeft,
Up => Key::Up, Up => Key::ArrowUp,
Right => Key::Right, Right => Key::ArrowRight,
Down => Key::Down, Down => Key::ArrowDown,
Back => Key::Backspace, Back => Key::Backspace,
Return => Key::Enter, Return => Key::Enter,
// Space => Key::Space,
Tab => Key::Tab, Tab => Key::Tab,
LAlt | RAlt => Key::Alt, A => Key::A,
LShift | RShift => Key::Shift, K => Key::K,
LControl | RControl => Key::Control, U => Key::U,
LWin | RWin => Key::Logo, W => Key::W,
Z => Key::Z,
_ => { _ => {
return None; return None;

View file

@ -92,16 +92,18 @@ impl egui::app::TextureAllocator for webgl::Painter {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// TODO: Just use RawInput?
/// Data gathered between frames. /// Data gathered between frames.
/// Is translated to `egui::RawInput` at the start of each frame. /// Is translated to `egui::RawInput` at the start of each frame.
#[derive(Default)] #[derive(Default)]
pub struct WebInput { pub struct WebInput {
/// In native points (not same as Egui points)
pub mouse_pos: Option<egui::Pos2>, 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, pub is_touch: bool,
/// In native points (not same as Egui points)
pub scroll_delta: egui::Vec2, pub scroll_delta: egui::Vec2,
pub events: Vec<egui::Event>,
pub raw: egui::RawInput,
} }
impl WebInput { impl WebInput {
@ -111,13 +113,12 @@ impl WebInput {
let scroll_delta = std::mem::take(&mut self.scroll_delta) * scale; 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)); let mouse_pos = self.mouse_pos.map(|mp| pos2(mp.x * scale, mp.y * scale));
egui::RawInput { egui::RawInput {
mouse_down: self.mouse_down,
mouse_pos, mouse_pos,
scroll_delta, scroll_delta,
screen_size: screen_size_in_native_points().unwrap() * scale, screen_size: screen_size_in_native_points().unwrap() * scale,
pixels_per_point: Some(pixels_per_point), pixels_per_point: Some(pixels_per_point),
time: now_sec(), time: now_sec(),
events: std::mem::take(&mut self.events), ..self.raw.take()
} }
} }
} }
@ -127,7 +128,7 @@ impl WebInput {
pub struct AppRunner { pub struct AppRunner {
pixels_per_point: f32, pixels_per_point: f32,
pub web_backend: WebBackend, pub web_backend: WebBackend,
pub web_input: WebInput, pub input: WebInput,
pub app: Box<dyn App>, pub app: Box<dyn App>,
pub needs_repaint: bool, // TODO: move pub needs_repaint: bool, // TODO: move
} }
@ -138,7 +139,7 @@ impl AppRunner {
Ok(Self { Ok(Self {
pixels_per_point: native_pixels_per_point(), pixels_per_point: native_pixels_per_point(),
web_backend, web_backend,
web_input: Default::default(), input: Default::default(),
app, app,
needs_repaint: true, // TODO: move needs_repaint: true, // TODO: move
}) })
@ -151,7 +152,7 @@ impl AppRunner {
pub fn logic(&mut self) -> Result<(egui::Output, egui::PaintJobs), JsValue> { pub fn logic(&mut self) -> Result<(egui::Output, egui::PaintJobs), JsValue> {
resize_canvas_to_screen_size(self.web_backend.canvas_id()); 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); self.web_backend.begin_frame(raw_input);
let mut integration_context = egui::app::IntegrationContext { let mut integration_context = egui::app::IntegrationContext {

View file

@ -187,14 +187,38 @@ pub fn location_hash() -> Option<String> {
web_sys::window()?.location().hash().ok() 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. /// a real text input or the name of a key.
fn should_ignore_key(key: &str) -> bool { fn should_ignore_key(key: &str) -> bool {
let is_function_key = key.starts_with('F') && key.len() > 1; let is_function_key = key.starts_with('F') && key.len() > 1;
is_function_key is_function_key
|| matches!( || matches!(
key, 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. /// a real text input or the name of a key.
pub fn translate_key(key: &str) -> Option<egui::Key> { pub fn translate_key(key: &str) -> Option<egui::Key> {
match 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), "Backspace" => Some(egui::Key::Backspace),
"Control" => Some(egui::Key::Control),
"Delete" => Some(egui::Key::Delete), "Delete" => Some(egui::Key::Delete),
"ArrowDown" => Some(egui::Key::Down),
"End" => Some(egui::Key::End), "End" => Some(egui::Key::End),
"Enter" => Some(egui::Key::Enter),
"Esc" | "Escape" => Some(egui::Key::Escape), "Esc" | "Escape" => Some(egui::Key::Escape),
"Home" => Some(egui::Key::Home),
"Help" | "Insert" => Some(egui::Key::Insert), "Help" | "Insert" => Some(egui::Key::Insert),
"ArrowLeft" => Some(egui::Key::Left), "Home" => Some(egui::Key::Home),
"Meta" => Some(egui::Key::Logo),
"PageDown" => Some(egui::Key::PageDown), "PageDown" => Some(egui::Key::PageDown),
"PageUp" => Some(egui::Key::PageUp), "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), "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, _ => 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/ // https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/
return; return;
} }
let mut runner_lock = runner_ref.0.lock(); let mut runner_lock = runner_ref.0.lock();
let modifiers = modifiers_from_event(&event);
runner_lock.input.raw.modifiers = modifiers;
let key = event.key(); let key = event.key();
if !should_ignore_key(&key) {
if let Some(key) = translate_key(&key) { if let Some(key) = translate_key(&key) {
runner_lock runner_lock.input.raw.events.push(egui::Event::Key {
.web_input key,
.events pressed: true,
.push(egui::Event::Key { key, pressed: true }); modifiers,
} else { });
runner_lock.web_input.events.push(egui::Event::Text(key));
}
runner_lock.needs_repaint = true;
} }
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(_)>); }) as Box<dyn FnMut(_)>);
document.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref())?; document.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref())?;
closure.forget(); closure.forget();
@ -290,14 +320,16 @@ fn install_document_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> {
let runner_ref = runner_ref.clone(); let runner_ref = runner_ref.clone();
let closure = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| { let closure = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| {
let mut runner_lock = runner_ref.0.lock(); let mut runner_lock = runner_ref.0.lock();
let key = event.key(); let modifiers = modifiers_from_event(&event);
if let Some(key) = translate_key(&key) { runner_lock.input.raw.modifiers = modifiers;
runner_lock.web_input.events.push(egui::Event::Key { if let Some(key) = translate_key(&event.key()) {
runner_lock.input.raw.events.push(egui::Event::Key {
key, key,
pressed: false, pressed: false,
modifiers,
}); });
runner_lock.needs_repaint = true;
} }
runner_lock.needs_repaint = true;
}) as Box<dyn FnMut(_)>); }) as Box<dyn FnMut(_)>);
document.add_event_listener_with_callback("keyup", closure.as_ref().unchecked_ref())?; document.add_event_listener_with_callback("keyup", closure.as_ref().unchecked_ref())?;
closure.forget(); closure.forget();
@ -315,6 +347,22 @@ fn install_document_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> {
Ok(()) 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> { fn install_canvas_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> {
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
let canvas = canvas_element(runner_ref.0.lock().canvas_id()).unwrap(); 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 runner_ref = runner_ref.clone();
let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| { let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
let mut runner_lock = runner_ref.0.lock(); let mut runner_lock = runner_ref.0.lock();
if !runner_lock.web_input.is_touch { if !runner_lock.input.is_touch {
runner_lock.web_input.mouse_pos = runner_lock.input.mouse_pos =
Some(pos_from_mouse_event(runner_lock.canvas_id(), &event)); 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.logic().unwrap(); // in case we get "mouseup" the same frame. TODO: handle via events instead
runner_lock.needs_repaint = true; runner_lock.needs_repaint = true;
event.stop_propagation(); event.stop_propagation();
@ -343,8 +391,8 @@ fn install_canvas_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> {
let runner_ref = runner_ref.clone(); let runner_ref = runner_ref.clone();
let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| { let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
let mut runner_lock = runner_ref.0.lock(); let mut runner_lock = runner_ref.0.lock();
if !runner_lock.web_input.is_touch { if !runner_lock.input.is_touch {
runner_lock.web_input.mouse_pos = runner_lock.input.mouse_pos =
Some(pos_from_mouse_event(runner_lock.canvas_id(), &event)); Some(pos_from_mouse_event(runner_lock.canvas_id(), &event));
runner_lock.needs_repaint = true; runner_lock.needs_repaint = true;
event.stop_propagation(); event.stop_propagation();
@ -360,10 +408,10 @@ fn install_canvas_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> {
let runner_ref = runner_ref.clone(); let runner_ref = runner_ref.clone();
let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| { let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
let mut runner_lock = runner_ref.0.lock(); let mut runner_lock = runner_ref.0.lock();
if !runner_lock.web_input.is_touch { if !runner_lock.input.is_touch {
runner_lock.web_input.mouse_pos = runner_lock.input.mouse_pos =
Some(pos_from_mouse_event(runner_lock.canvas_id(), &event)); 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; runner_lock.needs_repaint = true;
event.stop_propagation(); event.stop_propagation();
event.prevent_default(); event.prevent_default();
@ -378,8 +426,8 @@ fn install_canvas_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> {
let runner_ref = runner_ref.clone(); let runner_ref = runner_ref.clone();
let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| { let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
let mut runner_lock = runner_ref.0.lock(); let mut runner_lock = runner_ref.0.lock();
if !runner_lock.web_input.is_touch { if !runner_lock.input.is_touch {
runner_lock.web_input.mouse_pos = None; runner_lock.input.mouse_pos = None;
runner_lock.needs_repaint = true; runner_lock.needs_repaint = true;
event.stop_propagation(); event.stop_propagation();
event.prevent_default(); event.prevent_default();
@ -394,9 +442,9 @@ fn install_canvas_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> {
let runner_ref = runner_ref.clone(); let runner_ref = runner_ref.clone();
let closure = Closure::wrap(Box::new(move |event: web_sys::TouchEvent| { let closure = Closure::wrap(Box::new(move |event: web_sys::TouchEvent| {
let mut runner_lock = runner_ref.0.lock(); let mut runner_lock = runner_ref.0.lock();
runner_lock.web_input.is_touch = true; runner_lock.input.is_touch = true;
runner_lock.web_input.mouse_pos = Some(pos_from_touch_event(&event)); runner_lock.input.mouse_pos = Some(pos_from_touch_event(&event));
runner_lock.web_input.mouse_down = true; runner_lock.input.raw.mouse_down = true;
runner_lock.needs_repaint = true; runner_lock.needs_repaint = true;
event.stop_propagation(); event.stop_propagation();
event.prevent_default(); event.prevent_default();
@ -410,8 +458,8 @@ fn install_canvas_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> {
let runner_ref = runner_ref.clone(); let runner_ref = runner_ref.clone();
let closure = Closure::wrap(Box::new(move |event: web_sys::TouchEvent| { let closure = Closure::wrap(Box::new(move |event: web_sys::TouchEvent| {
let mut runner_lock = runner_ref.0.lock(); let mut runner_lock = runner_ref.0.lock();
runner_lock.web_input.is_touch = true; runner_lock.input.is_touch = true;
runner_lock.web_input.mouse_pos = Some(pos_from_touch_event(&event)); runner_lock.input.mouse_pos = Some(pos_from_touch_event(&event));
runner_lock.needs_repaint = true; runner_lock.needs_repaint = true;
event.stop_propagation(); event.stop_propagation();
event.prevent_default(); event.prevent_default();
@ -425,10 +473,10 @@ fn install_canvas_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> {
let runner_ref = runner_ref.clone(); let runner_ref = runner_ref.clone();
let closure = Closure::wrap(Box::new(move |event: web_sys::TouchEvent| { let closure = Closure::wrap(Box::new(move |event: web_sys::TouchEvent| {
let mut runner_lock = runner_ref.0.lock(); let mut runner_lock = runner_ref.0.lock();
runner_lock.web_input.is_touch = true; runner_lock.input.is_touch = true;
runner_lock.web_input.mouse_down = false; // First release mouse to click... 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.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; runner_lock.needs_repaint = true;
event.stop_propagation(); event.stop_propagation();
event.prevent_default(); event.prevent_default();
@ -442,8 +490,8 @@ fn install_canvas_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> {
let runner_ref = runner_ref.clone(); let runner_ref = runner_ref.clone();
let closure = Closure::wrap(Box::new(move |event: web_sys::WheelEvent| { let closure = Closure::wrap(Box::new(move |event: web_sys::WheelEvent| {
let mut runner_lock = runner_ref.0.lock(); let mut runner_lock = runner_ref.0.lock();
runner_lock.web_input.scroll_delta.x -= event.delta_x() as f32; runner_lock.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.y -= event.delta_y() as f32;
runner_lock.needs_repaint = true; runner_lock.needs_repaint = true;
event.stop_propagation(); event.stop_propagation();
event.prevent_default(); event.prevent_default();