From c0e7f947ff3ca168fd6dc477a113b8f9eb483cee Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sat, 16 May 2020 19:38:46 +0200 Subject: [PATCH] Improved text edit with cursor that can be moved with arrow keys --- emigui/src/font.rs | 77 +++++++++++++++---- emigui/src/input.rs | 2 +- emigui/src/math.rs | 5 +- emigui/src/memory.rs | 4 +- emigui/src/widgets.rs | 2 +- emigui/src/widgets/text_edit.rs | 128 +++++++++++++++++++++++++------- emigui_glium/src/lib.rs | 28 ++++++- 7 files changed, 199 insertions(+), 47 deletions(-) diff --git a/emigui/src/font.rs b/emigui/src/font.rs index af0f8b32..2257ffb3 100644 --- a/emigui/src/font.rs +++ b/emigui/src/font.rs @@ -52,7 +52,8 @@ pub struct Galley { /// The number of chars in all lines sum up to text.chars().count() pub lines: Vec, - // TODO: remove? Can just calculate on the fly + // We need size here to keep track of extra newline at the end. Hacky. Should fix. + // Newlines should probably be part of the start of the line? pub size: Vec2, } @@ -65,10 +66,40 @@ impl Galley { } 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_offset); + } + char_count += line_char_count; + } + + if let Some(last) = self.lines.last() { + if self.text.ends_with('\n') { + // The position of the next character will be here: + vec2(0.0, 0.5 * (self.size.y + last.y_offset)) // TODO: fix this hack + } else { + vec2(last.max_x(), last.y_offset) + } + } else { + // Empty galley + vec2(0.0, 0.0) + } + } } // ---------------------------------------------------------------------------- +// const REPLACEMENT_CHAR: char = '\u{25A1}'; // □ white square Replaces a missing or unsupported Unicode character. +// const REPLACEMENT_CHAR: char = '\u{FFFD}'; // � REPLACEMENT CHARACTER +const REPLACEMENT_CHAR: char = '?'; + #[derive(Clone, Copy, Debug)] pub struct UvRect { /// X/Y offset for nice rendering (unit: points). @@ -128,6 +159,8 @@ impl Font { for c in (FIRST_ASCII..=LAST_ASCII).map(|c| c as u8 as char) { font.add_char(c); } + font.add_char(REPLACEMENT_CHAR); + font } @@ -148,11 +181,20 @@ impl Font { self.glyph_infos.get(&c).and_then(|gi| gi.uv_rect) } - fn glyph_info(&self, c: char) -> Option<&GlyphInfo> { + fn glyph_info_or_none(&self, c: char) -> Option<&GlyphInfo> { self.glyph_infos.get(&c) } + fn glyph_info_or_replacemnet(&self, c: char) -> &GlyphInfo { + self.glyph_info_or_none(c) + .unwrap_or_else(|| self.glyph_info_or_none(REPLACEMENT_CHAR).unwrap()) + } + fn add_char(&mut self, c: char) { + if self.glyph_infos.contains_key(&c) { + return; + } + let glyph = self.font.glyph(c); assert_ne!( glyph.id().0, @@ -224,19 +266,18 @@ impl Font { let mut last_glyph_id = None; for c in text.chars() { - if let Some(glyph) = self.glyph_info(c) { - if let Some(last_glyph_id) = last_glyph_id { - cursor_x_in_points += - self.font - .pair_kerning(scale_in_pixels, last_glyph_id, glyph.id) - / self.pixels_per_point - } - cursor_x_in_points += glyph.advance_width; - cursor_x_in_points = self.round_to_pixel(cursor_x_in_points); - last_glyph_id = Some(glyph.id); - } else { - // Ignore unknown glyph + let glyph = self.glyph_info_or_replacemnet(c); + + if let Some(last_glyph_id) = last_glyph_id { + cursor_x_in_points += + self.font + .pair_kerning(scale_in_pixels, last_glyph_id, glyph.id) + / self.pixels_per_point } + cursor_x_in_points += glyph.advance_width; + cursor_x_in_points = self.round_to_pixel(cursor_x_in_points); + last_glyph_id = Some(glyph.id); + x_offsets.push(cursor_x_in_points); } @@ -352,16 +393,20 @@ impl Font { self.layout_paragraph_max_width(paragraph_text, max_width_in_points); assert!(!paragraph_lines.is_empty()); - let line_height = paragraph_lines.last().unwrap().y_offset + line_spacing; + let paragraph_height = paragraph_lines.last().unwrap().y_offset + line_spacing; for line in &mut paragraph_lines { line.y_offset += cursor_y; } lines.append(&mut paragraph_lines); - cursor_y += line_height; // TODO: add extra spacing between paragraphs + cursor_y += paragraph_height; // TODO: add extra spacing between paragraphs paragraph_start = paragraph_end; } + if text.ends_with('\n') { + cursor_y += line_spacing; + } + let mut widest_line = 0.0; for line in &lines { widest_line = line.max_x().max(widest_line); diff --git a/emigui/src/input.rs b/emigui/src/input.rs index d154a45f..6c7c16dc 100644 --- a/emigui/src/input.rs +++ b/emigui/src/input.rs @@ -120,7 +120,7 @@ pub enum Event { }, } -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Key { Alt, diff --git a/emigui/src/math.rs b/emigui/src/math.rs index 4ef7a385..ece314f0 100644 --- a/emigui/src/math.rs +++ b/emigui/src/math.rs @@ -547,7 +547,10 @@ pub fn remap_clamp(x: f32, from: RangeInclusive, to: RangeInclusive) - } } -pub fn clamp(x: f32, range: RangeInclusive) -> f32 { +pub fn clamp(x: T, range: RangeInclusive) -> T +where + T: Copy + PartialOrd, +{ if x <= *range.start() { *range.start() } else if *range.end() <= x { diff --git a/emigui/src/memory.rs b/emigui/src/memory.rs index 6915809f..7090bad7 100644 --- a/emigui/src/memory.rs +++ b/emigui/src/memory.rs @@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet}; use crate::{ containers::{area, collapsing_header, menu, resize, scroll_area}, + widgets::text_edit, Id, Layer, Pos2, Rect, }; @@ -19,8 +20,9 @@ pub struct Memory { // states of various types of widgets pub(crate) collapsing_headers: HashMap, pub(crate) menu_bar: HashMap, - pub(crate) scroll_areas: HashMap, pub(crate) resize: HashMap, + pub(crate) scroll_areas: HashMap, + pub(crate) text_edit: HashMap, pub(crate) areas: Areas, } diff --git a/emigui/src/widgets.rs b/emigui/src/widgets.rs index 4bd1f446..0ceb6501 100644 --- a/emigui/src/widgets.rs +++ b/emigui/src/widgets.rs @@ -3,7 +3,7 @@ use crate::{layout::Direction, GuiResponse, *}; mod slider; -mod text_edit; +pub mod text_edit; pub use {slider::*, text_edit::*}; diff --git a/emigui/src/widgets/text_edit.rs b/emigui/src/widgets/text_edit.rs index 2ab6db03..31e4feb5 100644 --- a/emigui/src/widgets/text_edit.rs +++ b/emigui/src/widgets/text_edit.rs @@ -1,5 +1,11 @@ use crate::*; +#[derive(Clone, Copy, Debug, Default, serde_derive::Deserialize, serde_derive::Serialize)] +pub(crate) struct State { + /// Charctaer based, NOT bytes + pub cursor: Option, +} + #[derive(Debug)] pub struct TextEdit<'t> { text: &'t mut String, @@ -36,12 +42,22 @@ impl<'t> TextEdit<'t> { impl<'t> Widget for TextEdit<'t> { fn ui(self, ui: &mut Ui) -> GuiResponse { - let id = ui.make_child_id(self.id); + let TextEdit { + text, + id, + text_style, + text_color, + } = self; - let font = &ui.fonts()[self.text_style]; + let id = ui.make_child_id(id); + + let mut state = ui.memory().text_edit.get(&id).cloned().unwrap_or_default(); + + let font = &ui.fonts()[text_style]; let line_spacing = font.line_spacing(); - let galley = font.layout_multiline(self.text.as_str(), ui.available().width()); - let desired_size = galley.size.max(vec2(ui.available().width(), line_spacing)); + let available_width = ui.available().width(); + let mut galley = font.layout_multiline(text.as_str(), available_width); + let desired_size = galley.size.max(vec2(available_width, line_spacing)); let interact = ui.reserve_space(desired_size, Some(id)); if interact.clicked { @@ -53,27 +69,31 @@ impl<'t> Widget for TextEdit<'t> { let has_kb_focus = ui.has_kb_focus(id); if has_kb_focus { + let mut cursor = state.cursor.unwrap_or_else(|| text.chars().count()); + cursor = clamp(cursor, 0..=text.chars().count()); + for event in &ui.input().events { match event { Event::Copy | Event::Cut => { // TODO: cut - ui.ctx().output().copied_text = self.text.clone(); + ui.ctx().output().copied_text = text.clone(); } - Event::Text(text) => { - if text == "\u{7f}" { - // backspace - } else { - *self.text += text; - } + Event::Text(text_to_insert) => { + insert_text(&mut cursor, text, text_to_insert); } Event::Key { key, pressed: true } => { - if *key == Key::Backspace { - self.text.pop(); // TODO: unicode aware - } + on_key_press(&mut cursor, text, *key); } _ => {} } } + state.cursor = Some(cursor); + + // layout again to avoid frame delay: + let font = &ui.fonts()[text_style]; + galley = font.layout_multiline(text.as_str(), available_width); + + // dbg!(&galley); } ui.add_paint_cmd(PaintCmd::Rect { @@ -90,21 +110,77 @@ impl<'t> Widget for TextEdit<'t> { let show_cursor = (ui.input().time * cursor_blink_hz as f64 * 3.0).floor() as i64 % 3 != 0; if show_cursor { - let cursor_pos = if let Some(last) = galley.lines.last() { - interact.rect.min + vec2(last.max_x(), last.y_offset) - } else { - interact.rect.min - }; - ui.add_paint_cmd(PaintCmd::line_segment( - [cursor_pos, cursor_pos + vec2(0.0, line_spacing)], - color::WHITE, - ui.style().text_cursor_width, - )); + if let Some(cursor) = state.cursor { + let cursor_pos = interact.rect.min + galley.char_start_pos(cursor); + ui.add_paint_cmd(PaintCmd::line_segment( + [cursor_pos, cursor_pos + vec2(0.0, line_spacing)], + color::WHITE, + ui.style().text_cursor_width, + )); + } } } - ui.add_galley(interact.rect.min, galley, self.text_style, self.text_color); - + ui.add_galley(interact.rect.min, galley, text_style, text_color); + ui.memory().text_edit.insert(id, state); ui.response(interact) } } + +fn insert_text(cursor: &mut usize, text: &mut String, text_to_insert: &str) { + // eprintln!("insert_text before: '{}', cursor at {}", text, cursor); + + let mut char_it = text.chars(); + let mut new_text = String::with_capacity(text.capacity()); + for _ in 0..*cursor { + let c = char_it.next().unwrap(); + new_text.push(c); + } + *cursor += text_to_insert.chars().count(); + new_text += text_to_insert; + new_text.extend(char_it); + *text = new_text; + + // eprintln!("insert_text after: '{}', cursor at {}\n", text, cursor); +} +fn on_key_press(cursor: &mut usize, text: &mut String, key: Key) { + // eprintln!("on_key_press before: '{}', cursor at {}", text, cursor); + + match key { + Key::Backspace if *cursor > 0 => { + *cursor -= 1; + + let mut char_it = text.chars(); + let mut new_text = String::with_capacity(text.capacity()); + for _ in 0..*cursor { + new_text.push(char_it.next().unwrap()) + } + new_text.extend(char_it.skip(1)); + *text = new_text; + } + Key::Delete => { + let mut char_it = text.chars(); + let mut new_text = String::with_capacity(text.capacity()); + for _ in 0..*cursor { + new_text.push(char_it.next().unwrap()) + } + new_text.extend(char_it.skip(1)); + *text = new_text; + } + Key::Home => { + *cursor = 0; // TODO: start of line + } + Key::End => { + *cursor = text.chars().count(); // TODO: end of line + } + Key::Left if *cursor > 0 => { + *cursor -= 1; + } + Key::Right => { + *cursor = (*cursor + 1).min(text.chars().count()); + } + _ => {} + } + + // eprintln!("on_key_press after: '{}', cursor at {}\n", text, cursor); +} diff --git a/emigui_glium/src/lib.rs b/emigui_glium/src/lib.rs index 7b66c6ba..37eeccee 100644 --- a/emigui_glium/src/lib.rs +++ b/emigui_glium/src/lib.rs @@ -48,7 +48,13 @@ pub fn input_event( raw_input.mouse_pos = None; } ReceivedCharacter(ch) => { - raw_input.events.push(Event::Text(ch.to_string())); + if !should_ignore_char(ch) { + if ch == '\r' { + raw_input.events.push(Event::Text("\n".to_owned())); + } else { + raw_input.events.push(Event::Text(ch.to_string())); + } + } } KeyboardInput { input, .. } => { if let Some(virtual_keycode) = input.virtual_keycode { @@ -103,6 +109,26 @@ pub fn input_event( } } +fn should_ignore_char(chr: char) -> bool { + // Glium sends some keys as chars: + match chr { + '\u{7f}' | // backspace + '\u{f728}' | // delete + '\u{f700}' | // up + '\u{f701}' | // down + '\u{f702}' | // left + '\u{f703}' | // right + '\u{f729}' | // home + '\u{f72b}' | // end + '\u{f72c}' | // page up + '\u{f72d}' | // page down + '\u{f710}' | // print screen + '\u{f704}' | '\u{f705}' // F1, F2, ... + => true, + _ => false, + } +} + pub fn translate_virtual_key_code(key: glutin::VirtualKeyCode) -> Option { use VirtualKeyCode::*;