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

This commit is contained in:
Emil Ernerfeldt 2020-11-14 21:01:21 +01:00
parent 7494026139
commit c4ed507d63
6 changed files with 234 additions and 106 deletions

View file

@ -16,6 +16,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
* 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
* Renamed and removed some keys in the `Key` enum.
### Fixed 🐛 ### Fixed 🐛

View file

@ -151,7 +151,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 +161,45 @@ 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,
} }
impl InputState { impl InputState {
@ -227,7 +239,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 +253,8 @@ impl InputState {
event, event,
Event::Key { Event::Key {
key, key,
pressed: false pressed: false,
..
} if *key == desired_key } if *key == desired_key
) )
}) })

View file

@ -621,6 +621,39 @@ impl Galley {
column: self.rows[cursor.rcursor.row].char_count_excluding_newline(), column: self.rows[cursor.rcursor.row].char_count_excluding_newline(),
}) })
} }
pub fn cursor_next_word(&self, cursor: &Cursor) -> Cursor {
self.from_ccursor(CCursor {
index: next_word(self.text.chars(), cursor.ccursor.index),
prefer_next_row: true,
})
}
pub fn cursor_previous_word(&self, cursor: &Cursor) -> Cursor {
let num_chars = self.text.chars().count();
self.from_ccursor(CCursor {
index: num_chars - next_word(self.text.chars().rev(), num_chars - cursor.ccursor.index),
prefer_next_row: true,
})
}
}
fn next_word(it: impl Iterator<Item = char>, mut index: usize) -> usize {
let mut it = it.skip(index);
if let Some(_first) = it.next() {
index += 1;
if let Some(second) = it.next() {
index += 1;
for next in it {
if next.is_alphabetic() != second.is_alphabetic() {
break;
}
index += 1;
}
}
}
index
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------

View file

@ -176,7 +176,7 @@ 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 response.clicked && enabled {
ui.memory().request_kb_focus(id); ui.memory().request_kb_focus(id);
@ -228,6 +228,7 @@ impl<'t> Widget for TextEdit<'t> {
Event::Key { Event::Key {
key: Key::Enter, key: Key::Enter,
pressed: true, pressed: true,
..
} => { } => {
if multiline { if multiline {
let mut ccursor = cursor.ccursor; let mut ccursor = cursor.ccursor;
@ -242,13 +243,16 @@ 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 } => { Event::Key {
on_key_press(&mut cursor, text, &galley, *key) key,
} pressed: true,
modifiers,
} => on_key_press(&mut cursor, text, &galley, *key, modifiers),
Event::Key { .. } => None, Event::Key { .. } => None,
}; };
@ -334,18 +338,35 @@ fn on_key_press(
text: &mut String, text: &mut String,
galley: &Galley, galley: &Galley,
key: Key, key: Key,
modifiers: &Modifiers,
) -> Option<CCursor> { ) -> Option<CCursor> {
// TODO: cursor position preview on mouse hover
// TODO: drag-select
// TODO: double-click to select whole word
// TODO: triple-click to select whole paragraph
// TODO: drag selected text to either move or clone (ctrl on windows, alt on mac)
// TODO: ctrl-U to clear paragraph before the cursor
// TODO: ctrl-W to delete previous word
// TODO: alt/ctrl + backspace to delete previous word (alt on mac, ctrl on windows)
// TODO: alt/ctrl + delete to delete next word (alt on mac, ctrl on windows)
// TODO: cmd-A to select all
// TODO: shift modifier to only move half of the cursor to select things
match key { match key {
Key::Backspace if cursor.ccursor.index > 0 => { Key::Backspace => {
*cursor = galley.from_ccursor(cursor.ccursor - 1); if cursor.ccursor.index > 0 {
let mut char_it = text.chars(); *cursor = galley.from_ccursor(cursor.ccursor - 1);
let mut new_text = String::with_capacity(text.capacity()); let mut char_it = text.chars();
for _ in 0..cursor.ccursor.index { let mut new_text = String::with_capacity(text.capacity());
new_text.push(char_it.next().unwrap()) for _ in 0..cursor.ccursor.index {
new_text.push(char_it.next().unwrap())
}
new_text.extend(char_it.skip(1));
*text = new_text;
Some(cursor.ccursor)
} else {
None
} }
new_text.extend(char_it.skip(1));
*text = new_text;
Some(cursor.ccursor)
} }
Key::Delete => { Key::Delete => {
let mut char_it = text.chars(); let mut char_it = text.chars();
@ -357,32 +378,69 @@ fn on_key_press(
*text = new_text; *text = new_text;
Some(cursor.ccursor) Some(cursor.ccursor)
} }
Key::Enter => unreachable!("Should have been handled earlier"),
Key::ArrowLeft => {
if modifiers.alt || modifiers.ctrl {
// alt on mac, ctrl on windows
*cursor = galley.cursor_previous_word(cursor);
} else if modifiers.mac_cmd {
*cursor = galley.cursor_begin_of_row(cursor);
} else {
*cursor = galley.cursor_left_one_character(cursor);
}
None
}
Key::ArrowRight => {
if modifiers.alt || modifiers.ctrl {
// alt on mac, ctrl on windows
*cursor = galley.cursor_next_word(cursor);
} else if modifiers.mac_cmd {
*cursor = galley.cursor_end_of_row(cursor);
} else {
*cursor = galley.cursor_right_one_character(cursor);
}
None
}
Key::ArrowUp => {
if modifiers.command {
// mac and windows behavior
*cursor = Cursor::default();
} else {
*cursor = galley.cursor_up_one_row(cursor);
}
None
}
Key::ArrowDown => {
if modifiers.command {
// mac and windows behavior
*cursor = galley.end();
} else {
*cursor = galley.cursor_down_one_row(cursor);
}
None
}
Key::Home => { Key::Home => {
*cursor = galley.cursor_begin_of_row(cursor); if modifiers.ctrl {
// windows behavior
*cursor = Cursor::default();
} else {
*cursor = galley.cursor_begin_of_row(cursor);
}
None None
} }
Key::End => { Key::End => {
*cursor = galley.cursor_end_of_row(cursor); if modifiers.ctrl {
// windows behavior
*cursor = galley.end();
} else {
*cursor = galley.cursor_end_of_row(cursor);
}
None None
} }
Key::Left => {
*cursor = galley.cursor_left_one_character(cursor); Key::Enter | Key::Escape => unreachable!("Handled outside this function"),
None
} Key::Insert | Key::PageDown | Key::PageUp | Key::Tab => None,
Key::Right => {
*cursor = galley.cursor_right_one_character(cursor);
None
}
Key::Up => {
*cursor = galley.cursor_up_one_row(cursor);
None
}
Key::Down => {
*cursor = galley.cursor_down_one_row(cursor);
None
}
_ => None,
} }
} }

View file

@ -20,10 +20,7 @@ pub use clipboard::ClipboardContext; // TODO: remove
pub struct GliumInputState { pub struct GliumInputState {
raw: egui::RawInput, raw: egui::RawInput,
modifiers: egui::Modifiers,
/// Command modifier key.
/// Mac command key on Mac, ctrl on Window/Linux.
cmd: bool,
} }
impl GliumInputState { impl GliumInputState {
@ -33,7 +30,7 @@ impl GliumInputState {
pixels_per_point: Some(pixels_per_point), pixels_per_point: Some(pixels_per_point),
..Default::default() ..Default::default()
}, },
cmd: false, modifiers: Default::default(), // cmd: false,
} }
} }
} }
@ -63,40 +60,48 @@ 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.modifiers.ctrl && !input_state.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.modifiers.alt = pressed;
}
if matches!(keycode, VirtualKeyCode::LControl | VirtualKeyCode::RControl) {
input_state.modifiers.ctrl = pressed;
if !cfg!(target_os = "macos") {
input_state.modifiers.command = pressed;
}
}
if matches!(keycode, VirtualKeyCode::LShift | VirtualKeyCode::RShift) {
input_state.modifiers.shift = pressed;
}
if cfg!(target_os = "macos")
&& matches!(keycode, VirtualKeyCode::LWin | VirtualKeyCode::RWin)
{
input_state.modifiers.mac_cmd = pressed;
input_state.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.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.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.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.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 +112,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.modifiers,
}); });
} }
} }
@ -157,20 +163,13 @@ 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,
LShift | RShift => Key::Shift,
LControl | RControl => Key::Control,
LWin | RWin => Key::Logo,
_ => { _ => {
return None; return None;
} }

View file

@ -187,14 +187,22 @@ 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"
| "CapsLock"
| "ContextMenu"
| "Control"
| "Meta"
| "NumLock"
| "Pause"
| "ScrollLock"
| "Shift"
) )
} }
@ -202,24 +210,20 @@ 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),
_ => None, _ => None,
} }
} }
@ -269,15 +273,16 @@ fn install_document_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> {
} }
let mut runner_lock = runner_ref.0.lock(); let mut runner_lock = runner_ref.0.lock();
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.web_input.events.push(egui::Event::Key {
.web_input key,
.events pressed: true,
.push(egui::Event::Key { key, pressed: true }); modifiers: modifiers_from_event(&event),
} else { });
runner_lock.web_input.events.push(egui::Event::Text(key)); runner_lock.needs_repaint = true;
} } else if !should_ignore_key(&key) {
runner_lock.web_input.events.push(egui::Event::Text(key));
runner_lock.needs_repaint = true; runner_lock.needs_repaint = true;
} }
}) as Box<dyn FnMut(_)>); }) as Box<dyn FnMut(_)>);
@ -295,6 +300,7 @@ fn install_document_events(runner_ref: &AppRunnerRef) -> Result<(), JsValue> {
runner_lock.web_input.events.push(egui::Event::Key { runner_lock.web_input.events.push(egui::Event::Key {
key, key,
pressed: false, pressed: false,
modifiers: modifiers_from_event(&event),
}); });
runner_lock.needs_repaint = true; runner_lock.needs_repaint = true;
} }
@ -315,6 +321,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();