use blitz_traits::{
events::{BlitzImeEvent, BlitzKeyEvent},
node_id::NodeId,
shell::ShellProvider,
};
use keyboard_types::{Code, Key, Modifiers};
use parley::{ContentWidths, FontContext, LayoutContext};
use crate::util::{ACTION_MOD, has_clipboard_modifier};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ClipboardCommand {
Copy,
Cut,
Paste,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum HistoryCommand {
Undo,
Redo,
}
fn history_command(event: &BlitzKeyEvent) -> Option<HistoryCommand> {
if !has_clipboard_modifier(event.modifiers) {
return None;
}
let shift = event.modifiers.contains(Modifiers::SHIFT);
let is = |code: Code, ch: &str| {
event.code == code || matches!(&event.key, Key::Character(c) if c.eq_ignore_ascii_case(ch))
};
if is(Code::KeyZ, "z") {
return Some(if shift {
HistoryCommand::Redo
} else {
HistoryCommand::Undo
});
}
if is(Code::KeyY, "y") {
return Some(HistoryCommand::Redo);
}
None
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct TextEditSnapshot {
text: String,
anchor: usize,
focus: usize,
}
#[derive(Debug, Default)]
pub struct TextEditHistory {
undo: Vec<TextEditSnapshot>,
redo: Vec<TextEditSnapshot>,
current: Option<TextEditSnapshot>,
burst: Option<TextEditSnapshot>,
applying: bool,
}
const MAX_UNDO_DEPTH: usize = 200;
impl TextEditHistory {
fn continues_burst(previous: &TextEditSnapshot, next: &TextEditSnapshot) -> bool {
if next.text.len() <= previous.text.len() {
return false;
}
if previous.anchor != previous.focus || next.anchor != next.focus {
return false;
}
let caret = previous.focus;
if caret > previous.text.len() || next.focus <= caret {
return false;
}
let added = next.focus - caret;
if next.text.len() != previous.text.len() + added {
return false;
}
if previous.text.get(..caret) != next.text.get(..caret) {
return false;
}
if previous.text.get(caret..) != next.text.get(next.focus..) {
return false;
}
!next.text[caret..next.focus]
.chars()
.any(|c| c.is_whitespace())
}
fn record(&mut self, snapshot: TextEditSnapshot) {
if self.applying {
return;
}
let Some(previous) = self.current.clone() else {
self.current = Some(snapshot);
return;
};
if previous == snapshot {
return;
}
self.redo.clear();
let burst = self.burst.as_ref().unwrap_or(&previous);
if Self::continues_burst(burst, &snapshot) {
self.burst = Some(burst.clone());
self.current = Some(snapshot);
return;
}
let entry = self.burst.take().unwrap_or(previous);
self.current = Some(snapshot);
self.undo.push(entry);
if self.undo.len() > MAX_UNDO_DEPTH {
self.undo.remove(0);
}
}
fn undo(&mut self, now: TextEditSnapshot) -> Option<TextEditSnapshot> {
if let Some(burst) = self.burst.take() {
if burst != now {
self.undo.push(burst);
}
}
let restore = self.undo.pop()?;
self.redo.push(now);
self.current = Some(restore.clone());
Some(restore)
}
fn redo(&mut self, now: TextEditSnapshot) -> Option<TextEditSnapshot> {
let restore = self.redo.pop()?;
self.undo.push(now);
self.burst = None;
self.current = Some(restore.clone());
Some(restore)
}
}
fn clipboard_command(event: &BlitzKeyEvent) -> Option<ClipboardCommand> {
if !has_clipboard_modifier(event.modifiers) {
return None;
}
match event.code {
Code::KeyC => Some(ClipboardCommand::Copy),
Code::KeyX => Some(ClipboardCommand::Cut),
Code::KeyV => Some(ClipboardCommand::Paste),
_ => match &event.key {
Key::Character(c) if c.eq_ignore_ascii_case("c") => Some(ClipboardCommand::Copy),
Key::Character(c) if c.eq_ignore_ascii_case("x") => Some(ClipboardCommand::Cut),
Key::Character(c) if c.eq_ignore_ascii_case("v") => Some(ClipboardCommand::Paste),
_ => None,
},
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct TextBrush {
pub id: NodeId,
}
impl TextBrush {
pub(crate) fn from_id(id: NodeId) -> Self {
Self { id }
}
}
#[derive(Clone, Debug)]
pub struct CachedContentWidths {
inline_box_widths: Box<[u32]>,
widths: ContentWidths,
}
#[derive(Clone, Default)]
pub struct TextLayout {
pub text: String,
pub content_widths: Option<CachedContentWidths>,
pub layout: parley::layout::Layout<TextBrush>,
pub laid_out_at: Option<f32>,
}
impl TextLayout {
pub fn new() -> Self {
Default::default()
}
pub fn content_widths(&mut self) -> ContentWidths {
let inline_box_widths: Box<[u32]> = self
.layout
.inline_boxes()
.iter()
.map(|ibox| ibox.width.to_bits())
.collect();
if let Some(cached) = &self.content_widths
&& cached.inline_box_widths == inline_box_widths
{
return cached.widths;
}
let widths = self.layout.calculate_content_widths();
self.content_widths = Some(CachedContentWidths {
inline_box_widths,
widths,
});
widths
}
}
impl std::fmt::Debug for TextLayout {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "TextLayout")
}
}
pub enum GeneratedTextInputEvent {
Input,
Select,
PreEditChange,
Submit,
}
pub struct TextInputData {
pub editor: Box<parley::PlainEditor<TextBrush>>,
pub placeholder_editor: Option<Box<parley::PlainEditor<TextBrush>>>,
history: TextEditHistory,
pub is_multiline: bool,
pub scroll_offset: f32,
pub layout_width: Option<f32>,
}
impl Clone for TextInputData {
fn clone(&self) -> Self {
TextInputData::new(self.is_multiline)
}
}
impl TextInputData {
pub fn new(is_multiline: bool) -> Self {
let editor = Box::new(parley::PlainEditor::new(16.0));
Self {
editor,
placeholder_editor: None,
history: TextEditHistory::default(),
is_multiline,
scroll_offset: 0.0,
layout_width: None,
}
}
fn snapshot(&self) -> TextEditSnapshot {
let selection = self.editor.raw_selection();
TextEditSnapshot {
text: self.editor.raw_text().to_string(),
anchor: selection.anchor().index(),
focus: selection.focus().index(),
}
}
fn record_history(&mut self) {
let snapshot = self.snapshot();
self.history.record(snapshot);
}
fn restore(
&mut self,
font_ctx: &mut FontContext,
layout_ctx: &mut LayoutContext<TextBrush>,
snapshot: &TextEditSnapshot,
) {
self.history.applying = true;
self.editor.set_text(&snapshot.text);
let mut driver = self.editor.driver(font_ctx, layout_ctx);
let len = snapshot.text.len();
let anchor = snapshot.anchor.min(len);
let focus = snapshot.focus.min(len);
if anchor == focus {
driver.move_to_byte(focus);
} else {
driver.select_byte_range(anchor, focus);
}
self.history.applying = false;
}
fn apply_history_command(
&mut self,
font_ctx: &mut FontContext,
layout_ctx: &mut LayoutContext<TextBrush>,
command: HistoryCommand,
) -> Option<GeneratedTextInputEvent> {
let now = self.snapshot();
let restore = match command {
HistoryCommand::Undo => self.history.undo(now),
HistoryCommand::Redo => self.history.redo(now),
}?;
self.restore(font_ctx, layout_ctx, &restore);
Some(GeneratedTextInputEvent::Input)
}
pub fn content_height(&self) -> Option<f32> {
self.editor
.try_layout()
.map(|layout| layout.height() / layout.scale())
}
fn apply_layout_width(&mut self) {
let Some(width) = self.layout_width else {
return;
};
self.editor.set_width(Some(width * self.editor.get_scale()));
if let Some(placeholder) = self.placeholder_editor.as_mut() {
placeholder.set_width(Some(width * placeholder.get_scale()));
}
}
pub fn sync_multiline_width(
&mut self,
font_ctx: &mut FontContext,
layout_ctx: &mut LayoutContext<TextBrush>,
width: f32,
) {
if !self.is_multiline || width <= 0.0 {
return;
}
if self
.layout_width
.is_some_and(|current| (current - width).abs() < 0.01)
{
return;
}
self.layout_width = Some(width);
self.apply_layout_width();
self.editor.driver(font_ctx, layout_ctx).refresh_layout();
if let Some(placeholder) = self.placeholder_editor.as_mut() {
placeholder.driver(font_ctx, layout_ctx).refresh_layout();
}
}
pub fn set_text(
&mut self,
font_ctx: &mut FontContext,
layout_ctx: &mut LayoutContext<TextBrush>,
text: &str,
) {
if self.editor.text() != text {
self.editor.set_text(text);
self.apply_layout_width();
self.editor.driver(font_ctx, layout_ctx).refresh_layout();
self.editor.driver(font_ctx, layout_ctx).move_to_text_end();
}
}
pub fn clamp_scroll_offset(&mut self, content_box_width: f32, content_box_height: f32) {
let Some(layout) = self.editor.try_layout() else {
return;
};
let scale = layout.scale();
let Some(caret) = self.editor.cursor_geometry(1.5) else {
return;
};
let (caret_start, caret_end, content, viewport) = if self.is_multiline {
(
caret.y0 as f32 / scale,
caret.y1 as f32 / scale,
layout.height() / scale,
content_box_height,
)
} else {
(
caret.x0 as f32 / scale,
caret.x1 as f32 / scale,
layout.full_width() / scale,
content_box_width,
)
};
let mut offset = self.scroll_offset;
if caret_end > offset + viewport {
offset = caret_end - viewport;
}
if caret_start < offset {
offset = caret_start;
}
let max_offset = (content.max(caret_end) - viewport).max(0.0);
self.scroll_offset = offset.clamp(0.0, max_offset);
}
pub fn max_scroll_offset(&self, content_box_width: f32, content_box_height: f32) -> f32 {
let Some(layout) = self.editor.try_layout() else {
return 0.0;
};
let scale = layout.scale();
let (content, viewport) = if self.is_multiline {
(layout.height() / scale, content_box_height)
} else {
(layout.full_width() / scale, content_box_width)
};
(content - viewport).max(0.0)
}
pub fn scroll_by(
&mut self,
delta: f32,
content_box_width: f32,
content_box_height: f32,
) -> f32 {
let max_offset = self.max_scroll_offset(content_box_width, content_box_height);
if max_offset <= 0.0 {
return delta;
}
let new_offset = (self.scroll_offset - delta).clamp(0.0, max_offset);
let consumed = self.scroll_offset - new_offset;
self.scroll_offset = new_offset;
delta - consumed
}
pub(crate) fn apply_keypress_event(
&mut self,
font_ctx: &mut FontContext,
layout_ctx: &mut LayoutContext<TextBrush>,
shell_provider: &dyn ShellProvider,
event: BlitzKeyEvent,
) -> Option<GeneratedTextInputEvent> {
if !event.state.is_pressed() {
return None;
}
if let Some(command) = history_command(&event) {
return self.apply_history_command(font_ctx, layout_ctx, command);
}
self.record_history();
let mods = event.modifiers;
let shift = mods.contains(Modifiers::SHIFT);
let action_mod = mods.contains(ACTION_MOD);
let word_mod = mods.contains(Modifiers::ALT);
let is_multiline = self.is_multiline;
let editor = &mut self.editor;
let mut driver = editor.driver(font_ctx, layout_ctx);
if let Some(command) = clipboard_command(&event) {
match command {
ClipboardCommand::Copy => {
if let Some(text) = driver.editor.selected_text() {
let _ = shell_provider.set_clipboard_text(text.to_owned());
}
}
ClipboardCommand::Cut => {
if let Some(text) = driver.editor.selected_text() {
let _ = shell_provider.set_clipboard_text(text.to_owned());
driver.delete_selection()
}
}
ClipboardCommand::Paste => {
let text = shell_provider.get_clipboard_text().unwrap_or_default();
driver.insert_or_replace_selection(&text)
}
}
return Some(GeneratedTextInputEvent::Input);
}
match event.key {
Key::Character(c) if action_mod && matches!(c.to_lowercase().as_str(), "a") => {
if shift {
driver.collapse_selection()
} else {
driver.select_all()
}
return Some(GeneratedTextInputEvent::Select);
}
Key::ArrowLeft => {
if action_mod {
if shift {
driver.select_to_line_start()
} else {
driver.move_to_line_start()
}
} else if word_mod {
if shift {
driver.select_word_left()
} else {
driver.move_word_left()
}
} else if shift {
driver.select_left()
} else {
driver.move_left()
}
return Some(GeneratedTextInputEvent::Select);
}
Key::ArrowRight => {
if action_mod {
if shift {
driver.select_to_line_end()
} else {
driver.move_to_line_end()
}
} else if word_mod {
if shift {
driver.select_word_right()
} else {
driver.move_word_right()
}
} else if shift {
driver.select_right()
} else {
driver.move_right()
}
return Some(GeneratedTextInputEvent::Select);
}
Key::ArrowUp => {
if action_mod && shift {
driver.select_to_text_start()
} else if action_mod {
driver.move_to_text_start()
} else if shift {
driver.select_up()
} else {
driver.move_up()
}
return Some(GeneratedTextInputEvent::Select);
}
Key::ArrowDown => {
if action_mod && shift {
driver.select_to_text_end()
} else if action_mod {
driver.move_to_text_end()
} else if shift {
driver.select_down()
} else {
driver.move_down()
}
return Some(GeneratedTextInputEvent::Select);
}
Key::Home => {
if action_mod {
if shift {
driver.select_to_text_start()
} else {
driver.move_to_text_start()
}
} else if shift {
driver.select_to_line_start()
} else {
driver.move_to_line_start()
}
return Some(GeneratedTextInputEvent::Select);
}
Key::End => {
if action_mod {
if shift {
driver.select_to_text_end()
} else {
driver.move_to_text_end()
}
} else if shift {
driver.select_to_line_end()
} else {
driver.move_to_line_end()
}
return Some(GeneratedTextInputEvent::Select);
}
Key::Delete => {
#[cfg(target_os = "macos")]
if mods.contains(Modifiers::SUPER) {
if driver.editor.raw_selection().is_collapsed() {
driver.select_to_line_end();
}
driver.delete_selection();
} else if mods.contains(Modifiers::ALT) {
driver.delete_word();
} else {
driver.delete();
}
#[cfg(not(target_os = "macos"))]
if action_mod {
driver.delete_word();
} else {
driver.delete();
}
return Some(GeneratedTextInputEvent::Input);
}
Key::Backspace => {
#[cfg(target_os = "macos")]
if mods.contains(Modifiers::SUPER) {
if driver.editor.raw_selection().is_collapsed() {
driver.select_to_line_start();
}
driver.delete_selection();
} else if mods.contains(Modifiers::ALT) {
driver.backdelete_word();
} else {
driver.backdelete();
}
#[cfg(not(target_os = "macos"))]
if action_mod {
driver.backdelete_word();
} else {
driver.backdelete();
}
return Some(GeneratedTextInputEvent::Input);
}
Key::Character(c) if c == "\n" => {
if is_multiline {
driver.insert_or_replace_selection("\n");
return Some(GeneratedTextInputEvent::Input);
} else {
return Some(GeneratedTextInputEvent::Submit);
}
}
Key::Enter => {
if is_multiline {
driver.insert_or_replace_selection("\n");
return Some(GeneratedTextInputEvent::Input);
} else {
return Some(GeneratedTextInputEvent::Submit);
}
}
Key::Character(s)
if !mods.contains(Modifiers::CONTROL) && !mods.contains(Modifiers::SUPER) =>
{
driver.insert_or_replace_selection(&s);
return Some(GeneratedTextInputEvent::Input);
}
_ => {}
};
None
}
pub(crate) fn apply_apple_standard_keybinding(
&mut self,
font_ctx: &mut FontContext,
layout_ctx: &mut LayoutContext<TextBrush>,
shell_provider: &dyn ShellProvider,
command: &str,
) -> Option<GeneratedTextInputEvent> {
self.record_history();
let editor = &mut self.editor;
let mut driver = editor.driver(font_ctx, layout_ctx);
let is_multiline = self.is_multiline;
match command {
"insertBacktab:" => {}
"insertContainerBreak:" => {}
"insertDoubleQuoteIgnoringSubstitution:" => {
driver.insert_or_replace_selection("\"");
return Some(GeneratedTextInputEvent::Input);
}
"insertLineBreak:" => {
driver.insert_or_replace_selection("\n");
return Some(GeneratedTextInputEvent::Input);
}
"insertNewline:" => {
if is_multiline {
driver.insert_or_replace_selection("\n");
return Some(GeneratedTextInputEvent::Input);
} else {
return Some(GeneratedTextInputEvent::Submit);
}
}
"insertNewlineIgnoringFieldEditor:" => {
driver.insert_or_replace_selection("\n");
return Some(GeneratedTextInputEvent::Input);
}
"insertParagraphSeparator:" => {
driver.insert_or_replace_selection("\n");
return Some(GeneratedTextInputEvent::Input);
}
"insertSingleQuoteIgnoringSubstitution:" => {
driver.insert_or_replace_selection("'");
return Some(GeneratedTextInputEvent::Input);
}
"insertTab:" | "insertTabIgnoringFieldEditor:" => {
}
"insertText:" => {}
"deleteBackward:" | "deleteBackwardByDecomposingPreviousCharacter:" => {}
"deleteForward:" => {}
"deleteToBeginningOfLine:" => {
if driver.editor.raw_selection().is_collapsed() {
driver.select_to_line_start();
}
driver.delete_selection();
return Some(GeneratedTextInputEvent::Input);
}
"deleteToEndOfLine:" => {
if driver.editor.raw_selection().is_collapsed() {
driver.select_to_line_end();
}
driver.delete_selection();
return Some(GeneratedTextInputEvent::Input);
}
"deleteToBeginningOfParagraph:" => {
if driver.editor.raw_selection().is_collapsed() {
driver.select_to_hard_line_start();
}
driver.delete_selection();
return Some(GeneratedTextInputEvent::Input);
}
"deleteToEndOfParagraph:" => {
if driver.editor.raw_selection().is_collapsed() {
driver.select_to_hard_line_end();
}
driver.delete_selection();
return Some(GeneratedTextInputEvent::Input);
}
"deleteWordBackward:" => {}
"deleteWordForward:" => {}
"yank:" => {
if let Some(text) = driver.editor.selected_text() {
let _ = shell_provider.set_clipboard_text(text.to_owned());
driver.delete_selection();
return Some(GeneratedTextInputEvent::Input);
}
}
"moveBackward:" => {
driver.move_left(); return Some(GeneratedTextInputEvent::Select);
}
"moveDown:" => {
driver.move_down();
return Some(GeneratedTextInputEvent::Select);
}
"moveForward:" => {
driver.move_right();
return Some(GeneratedTextInputEvent::Select);
}
"moveLeft:" => {
driver.move_left();
return Some(GeneratedTextInputEvent::Select);
}
"moveRight:" => {
driver.move_right();
return Some(GeneratedTextInputEvent::Select);
}
"moveUp:" => {
driver.move_up();
return Some(GeneratedTextInputEvent::Select);
}
"moveBackwardAndModifySelection:" => {
driver.select_left(); return Some(GeneratedTextInputEvent::Select);
}
"moveDownAndModifySelection:" => {
driver.select_down();
return Some(GeneratedTextInputEvent::Select);
}
"moveForwardAndModifySelection:" => {
driver.select_right(); return Some(GeneratedTextInputEvent::Select);
}
"moveLeftAndModifySelection:" => {
driver.select_left();
return Some(GeneratedTextInputEvent::Select);
}
"moveRightAndModifySelection:" => {
driver.select_right();
return Some(GeneratedTextInputEvent::Select);
}
"moveUpAndModifySelection:" => {
driver.select_up();
return Some(GeneratedTextInputEvent::Select);
}
"selectAll:" => {
driver.select_all();
return Some(GeneratedTextInputEvent::Select);
}
"selectLine:" => {
driver.move_to_line_start();
driver.select_to_line_end();
return Some(GeneratedTextInputEvent::Select);
}
"selectParagraph:" => {
driver.move_to_hard_line_start();
driver.select_to_hard_line_end();
return Some(GeneratedTextInputEvent::Select);
}
"selectWord:" => {
}
"moveToBeginningOfDocument:" => {
driver.move_to_text_start();
return Some(GeneratedTextInputEvent::Select);
}
"moveToBeginningOfDocumentAndModifySelection:" => {
driver.select_to_text_start();
return Some(GeneratedTextInputEvent::Select);
}
"moveToEndOfDocument:" => {
driver.move_to_text_end();
return Some(GeneratedTextInputEvent::Select);
}
"moveToEndOfDocumentAndModifySelection:" => {
driver.move_to_text_end();
return Some(GeneratedTextInputEvent::Select);
}
"moveParagraphBackwardAndModifySelection:" => {}
"moveParagraphForwardAndModifySelection:" => {}
"moveToBeginningOfParagraph:" => {
driver.move_to_hard_line_start();
return Some(GeneratedTextInputEvent::Select);
}
"moveToBeginningOfParagraphAndModifySelection:" => {
driver.select_to_hard_line_start();
return Some(GeneratedTextInputEvent::Select);
}
"moveToEndOfParagraph:" => {
driver.move_to_hard_line_end();
return Some(GeneratedTextInputEvent::Select);
}
"moveToEndOfParagraphAndModifySelection:" => {
driver.select_to_hard_line_end();
return Some(GeneratedTextInputEvent::Select);
}
"moveToBeginningOfLine:" => {
driver.move_to_line_start();
return Some(GeneratedTextInputEvent::Select);
}
"moveToBeginningOfLineAndModifySelection:" => {
driver.select_to_line_start();
return Some(GeneratedTextInputEvent::Select);
}
"moveToEndOfLine:" => {
driver.move_to_line_end();
return Some(GeneratedTextInputEvent::Select);
}
"moveToEndOfLineAndModifySelection:" => {
driver.select_to_line_end();
return Some(GeneratedTextInputEvent::Select);
}
"moveToLeftEndOfLine:" => {
driver.move_to_text_start();
return Some(GeneratedTextInputEvent::Select);
}
"moveToLeftEndOfLineAndModifySelection:" => {
driver.select_to_line_start();
return Some(GeneratedTextInputEvent::Select);
}
"moveToRightEndOfLine:" => {
driver.move_to_line_end();
return Some(GeneratedTextInputEvent::Select);
}
"moveToRightEndOfLineAndModifySelection:" => {
driver.select_to_line_end();
return Some(GeneratedTextInputEvent::Select);
}
"moveWordBackward:" => {
driver.move_word_left();
return Some(GeneratedTextInputEvent::Select);
}
"moveWordBackwardAndModifySelection:" => {
driver.select_word_left();
return Some(GeneratedTextInputEvent::Select);
}
"moveWordForward:" => {
driver.move_word_right();
return Some(GeneratedTextInputEvent::Select);
}
"moveWordForwardAndModifySelection:" => {
driver.select_word_right();
return Some(GeneratedTextInputEvent::Select);
}
"moveWordLeft:" => {
driver.move_word_left();
return Some(GeneratedTextInputEvent::Select);
}
"moveWordLeftAndModifySelection:" => {
driver.select_word_left();
return Some(GeneratedTextInputEvent::Select);
}
"moveWordRight:" => {
driver.move_word_right();
return Some(GeneratedTextInputEvent::Select);
}
"moveWordRightAndModifySelection:" => {
driver.select_word_right();
return Some(GeneratedTextInputEvent::Select);
}
"scrollPageDown:" => {}
"scrollPageUp:" => {}
"scrollLineDown:" => {}
"scrollLineUp:" => {}
"scrollToBeginningOfDocument:" => {}
"scrollToEndOfDocument:" => {}
"pageDown:" => {}
"pageUp:" => {}
"pageDownAndModifySelection:" => {}
"pageUpAndModifySelection:" => {}
"centerSelectionInVisibleArea:" => {}
"transpose:" => {}
"transposeWords:" => {}
"indent:" => {}
"cancelOperation:" => {}
"quickLookPreviewItems:" => {}
"makeBaseWritingDirectionLeftToRight:" => {}
"makeBaseWritingDirectionNatural:" => {}
"makeBaseWritingDirectionRightToLeft:" => {}
"makeTextWritingDirectionLeftToRight:" => {}
"makeTextWritingDirectionNatural:" => {}
"makeTextWritingDirectionRightToLeft:" => {}
"capitalizeWord:" => {}
"changeCaseOfLetter:" => {}
"lowercaseWord:" => {}
"uppercaseWord:" => {}
"setMark:" => {}
"selectToMark:" => {}
"deleteToMark:" => {}
"swapWithMark:" => {}
"complete:" => {}
"showContextMenuForSelection:" => {}
_ => {}
};
None
}
pub(crate) fn apply_ime_event(
&mut self,
font_ctx: &mut FontContext,
layout_ctx: &mut LayoutContext<TextBrush>,
event: BlitzImeEvent,
) -> Option<GeneratedTextInputEvent> {
if matches!(event, BlitzImeEvent::Commit(_)) {
self.record_history();
}
let editor = &mut self.editor;
let mut driver = editor.driver(font_ctx, layout_ctx);
match event {
BlitzImeEvent::Enabled => {
None
}
BlitzImeEvent::Disabled => {
driver.clear_compose();
Some(GeneratedTextInputEvent::PreEditChange)
}
BlitzImeEvent::Commit(text) => {
driver.insert_or_replace_selection(&text);
Some(GeneratedTextInputEvent::Input)
}
BlitzImeEvent::Preedit(text, cursor) => {
if text.is_empty() {
driver.clear_compose();
} else {
driver.set_compose(&text, cursor);
}
Some(GeneratedTextInputEvent::PreEditChange)
}
BlitzImeEvent::DeleteSurrounding {
before_bytes,
after_bytes,
} => {
let _ = before_bytes;
let _ = after_bytes;
None
}
}
}
}
#[cfg(test)]
mod content_widths_cache_tests {
use super::*;
use parley::{InlineBox, InlineBoxKind, TextStyle};
fn build_layout(text: &str, inline_box_width: Option<f32>) -> TextLayout {
let mut font_ctx = FontContext::default();
let mut layout_ctx = LayoutContext::new();
let style: TextStyle<'_, '_, TextBrush> = TextStyle::default();
let mut builder = layout_ctx.tree_builder(&mut font_ctx, 1.0, true, &style);
builder.push_text(text);
if let Some(width) = inline_box_width {
builder.push_inline_box(InlineBox {
id: 0,
kind: InlineBoxKind::InFlow,
index: text.len(),
width,
height: 10.0,
});
}
let mut text_layout = TextLayout::new();
text_layout.text = builder.build_into(&mut text_layout.layout);
text_layout
}
#[test]
fn first_call_matches_an_uncached_computation() {
let mut text_layout = build_layout("the quick brown fox", None);
let expected = text_layout.layout.calculate_content_widths();
let cached = text_layout.content_widths();
assert_eq!(cached.min, expected.min);
assert_eq!(cached.max, expected.max);
assert!(cached.min > 0.0);
assert!(cached.max > cached.min);
}
#[test]
fn text_only_layout_reuses_the_cached_widths() {
let mut text_layout = build_layout("the quick brown fox", None);
text_layout.content_widths();
let poison = ContentWidths {
min: -1.0,
max: -2.0,
};
text_layout.content_widths.as_mut().unwrap().widths = poison;
let second = text_layout.content_widths();
assert_eq!(second.min, poison.min);
assert_eq!(second.max, poison.max);
}
#[test]
fn a_changed_inline_box_width_forces_a_recompute() {
let mut text_layout = build_layout("the quick brown fox", Some(40.0));
let first = text_layout.content_widths();
text_layout.content_widths.as_mut().unwrap().widths = ContentWidths {
min: -1.0,
max: -2.0,
};
text_layout.layout.inline_boxes_mut()[0].width = 400.0;
let second = text_layout.content_widths();
assert!(second.min > 0.0);
assert!(second.max > first.max);
assert_eq!(second.min, 400.0);
}
#[test]
fn an_unchanged_inline_box_width_still_hits_the_cache() {
let mut text_layout = build_layout("the quick brown fox", Some(40.0));
text_layout.content_widths();
let poison = ContentWidths {
min: -1.0,
max: -2.0,
};
text_layout.content_widths.as_mut().unwrap().widths = poison;
text_layout.layout.inline_boxes_mut()[0].width = 40.0;
let second = text_layout.content_widths();
assert_eq!(second.min, poison.min);
assert_eq!(second.max, poison.max);
}
#[test]
fn rebuilding_the_layout_discards_the_cache() {
let mut text_layout = build_layout("the quick brown fox", None);
text_layout.content_widths();
assert!(text_layout.content_widths.is_some());
text_layout.content_widths = None;
let rebuilt = build_layout("a much much much longer run of text", None);
text_layout.layout = rebuilt.layout;
text_layout.text = rebuilt.text;
let widths = text_layout.content_widths();
let expected = text_layout.layout.calculate_content_widths();
assert_eq!(widths.max, expected.max);
}
}
#[cfg(test)]
mod shortcut_tests {
use super::*;
use blitz_traits::events::{BlitzKeyEvent, KeyState};
use blitz_traits::shell::DummyShellProvider;
use keyboard_types::Location;
fn control_event(key: Key, code: Code) -> BlitzKeyEvent {
BlitzKeyEvent {
key,
code,
modifiers: Modifiers::CONTROL,
location: Location::Standard,
is_auto_repeating: false,
is_composing: false,
state: KeyState::Pressed,
text: None,
}
}
#[test]
fn control_character_cut_uses_the_physical_key_code() {
let event = control_event(Key::Character("\u{18}".into()), Code::KeyX);
assert_eq!(clipboard_command(&event), Some(ClipboardCommand::Cut));
}
#[test]
fn backspace_does_not_depend_on_an_apple_standard_keybinding() {
let mut data = TextInputData::new(false);
let mut font_ctx = FontContext::default();
let mut layout_ctx = LayoutContext::new();
data.set_text(&mut font_ctx, &mut layout_ctx, "typo");
data.editor
.driver(&mut font_ctx, &mut layout_ctx)
.move_to_text_end();
let event = BlitzKeyEvent {
key: Key::Backspace,
code: Code::Backspace,
modifiers: Modifiers::empty(),
location: Location::Standard,
is_auto_repeating: false,
is_composing: false,
state: KeyState::Pressed,
text: None,
};
assert!(matches!(
data.apply_keypress_event(&mut font_ctx, &mut layout_ctx, &DummyShellProvider, event,),
Some(GeneratedTextInputEvent::Input)
));
assert_eq!(data.editor.raw_text(), "typ");
}
}
#[cfg(test)]
mod history_tests {
use super::*;
use blitz_traits::events::{BlitzKeyEvent, KeyState};
use blitz_traits::shell::DummyShellProvider;
use keyboard_types::Location;
struct Input {
data: TextInputData,
font_ctx: FontContext,
layout_ctx: LayoutContext<TextBrush>,
}
impl Input {
fn new() -> Self {
Self {
data: TextInputData::new(true),
font_ctx: FontContext::default(),
layout_ctx: LayoutContext::new(),
}
}
fn press(&mut self, key: Key, code: Code, modifiers: Modifiers) {
let event = BlitzKeyEvent {
key,
code,
modifiers,
location: Location::Standard,
is_auto_repeating: false,
is_composing: false,
state: KeyState::Pressed,
text: None,
};
self.data.apply_keypress_event(
&mut self.font_ctx,
&mut self.layout_ctx,
&DummyShellProvider,
event,
);
}
fn type_text(&mut self, text: &str) {
for ch in text.chars() {
self.press(
Key::Character(ch.to_string()),
Code::Unidentified,
Modifiers::empty(),
);
}
}
fn undo(&mut self) {
self.press(Key::Character("z".into()), Code::KeyZ, Modifiers::CONTROL);
}
fn redo(&mut self) {
self.press(
Key::Character("z".into()),
Code::KeyZ,
Modifiers::CONTROL | Modifiers::SHIFT,
);
}
fn text(&self) -> &str {
self.data.editor.raw_text()
}
}
#[test]
fn undo_restores_the_text_from_before_the_edit() {
let mut input = Input::new();
input.type_text("first");
input.type_text(" second");
input.undo();
assert_eq!(
input.text(),
"first ",
"undo should remove the most recent word",
);
}
#[test]
fn redo_reapplies_what_undo_removed() {
let mut input = Input::new();
input.type_text("first");
input.type_text(" second");
let full = input.text().to_string();
input.undo();
input.redo();
assert_eq!(input.text(), full, "redo should restore the undone text");
}
#[test]
fn a_run_of_typing_undoes_as_one_word_rather_than_per_character() {
let mut input = Input::new();
input.type_text("hello world");
input.undo();
assert_eq!(
input.text(),
"hello ",
"the burst should end at the space, not at the previous character",
);
}
#[test]
fn repeated_undo_walks_back_through_the_history() {
let mut input = Input::new();
input.type_text("one two three");
input.undo();
assert_eq!(input.text(), "one two ");
input.undo();
assert_eq!(input.text(), "one ");
input.undo();
assert_eq!(input.text(), "");
}
#[test]
fn undo_with_nothing_to_undo_leaves_the_text_alone() {
let mut input = Input::new();
input.type_text("only");
input.undo();
input.undo();
input.undo();
assert_eq!(input.text(), "");
}
#[test]
fn a_fresh_edit_after_an_undo_clears_the_redo_stack() {
let mut input = Input::new();
input.type_text("first");
input.type_text(" second");
input.undo();
assert_eq!(input.text(), "first ");
input.type_text("third");
input.redo();
assert_eq!(
input.text(),
"first third",
"redo must not resurrect a branch that was typed over",
);
}
#[test]
fn control_y_also_redoes() {
let mut input = Input::new();
input.type_text("first");
input.type_text(" second");
let full = input.text().to_string();
input.undo();
input.press(Key::Character("y".into()), Code::KeyY, Modifiers::CONTROL);
assert_eq!(input.text(), full);
}
#[test]
fn the_undo_chord_does_not_type_its_own_character() {
let mut input = Input::new();
input.type_text("text");
input.undo();
input.redo();
assert!(
!input.text().contains('z'),
"the undo chord leaked into the buffer: {:?}",
input.text(),
);
}
#[test]
fn undo_restores_the_selection_along_with_the_text() {
let mut input = Input::new();
input.type_text("alpha");
input.type_text(" beta");
input.undo();
let selection = input.data.editor.raw_selection();
assert_eq!(
selection.focus().index(),
input.text().len(),
"the caret should return to the end of the restored text",
);
}
#[test]
fn the_history_is_capped_at_the_maximum_depth() {
let mut history = TextEditHistory::default();
for i in 0..(MAX_UNDO_DEPTH + 50) {
history.record(TextEditSnapshot {
text: format!("state {i}"),
anchor: 0,
focus: 0,
});
}
assert!(
history.undo.len() <= MAX_UNDO_DEPTH,
"history grew to {} entries, past the {MAX_UNDO_DEPTH} cap",
history.undo.len(),
);
}
}
#[cfg(test)]
mod history_chord_tests {
use super::*;
use blitz_traits::events::{BlitzKeyEvent, KeyState};
use keyboard_types::Location;
fn event(key: Key, code: Code, modifiers: Modifiers) -> BlitzKeyEvent {
BlitzKeyEvent {
key,
code,
modifiers,
location: Location::Standard,
is_auto_repeating: false,
is_composing: false,
state: KeyState::Pressed,
text: None,
}
}
#[test]
fn undo_is_recognised_under_control_and_under_the_platform_modifier() {
for modifiers in [Modifiers::CONTROL, ACTION_MOD] {
assert_eq!(
history_command(&event(Key::Character("z".into()), Code::KeyZ, modifiers)),
Some(HistoryCommand::Undo),
);
}
}
#[test]
fn shift_z_redoes_under_either_modifier() {
for modifiers in [Modifiers::CONTROL, ACTION_MOD] {
assert_eq!(
history_command(&event(
Key::Character("z".into()),
Code::KeyZ,
modifiers | Modifiers::SHIFT,
)),
Some(HistoryCommand::Redo),
);
}
}
#[test]
fn a_remapped_character_still_undoes_by_its_physical_key() {
assert_eq!(
history_command(&event(
Key::Character("w".into()),
Code::KeyZ,
Modifiers::CONTROL,
)),
Some(HistoryCommand::Undo),
);
}
#[test]
fn the_chord_needs_a_modifier() {
assert_eq!(
history_command(&event(
Key::Character("z".into()),
Code::KeyZ,
Modifiers::empty(),
)),
None,
);
}
}