use blitz_traits::{ events::{BlitzImeEvent, BlitzKeyEvent}, node_id::NodeId, shell::ShellProvider, }; use keyboard_types::{Key, Modifiers, NamedKey}; use parley::{ContentWidths, FontContext, LayoutContext}; use crate::util::ACTION_MOD; #[derive(Debug, Clone, Copy, Default, PartialEq)] /// Parley Brush type for Blitz which contains the Blitz node id pub struct TextBrush { /// The node id for the span pub id: NodeId, } impl TextBrush { pub(crate) fn from_id(id: NodeId) -> Self { Self { id } } } #[derive(Clone, Default)] pub struct TextLayout { pub text: String, pub content_widths: Option, pub layout: parley::layout::Layout, /// Block-axis offset (in CSS px) of the line boxes from the top of the container's content /// box, as applied by `align-content`. pub block_offset: f32, /// The out-of-flow positions for which some inline span with an inline box below it is a /// containing block (as `SPAN_*_CB` flags). The inline root claims those boxes in its place. /// Set by each `RunMode::PerformLayout` run of inline layout. pub span_cb_flags: stylo_taffy::StyleFlags, } impl TextLayout { pub fn new() -> Self { Default::default() } pub fn content_widths(&mut self) -> ContentWidths { *self .content_widths .get_or_insert_with(|| self.layout.calculate_content_widths()) } } impl std::fmt::Debug for TextLayout { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "TextLayout") } } // TODO: support keypress events pub enum GeneratedTextInputEvent { Input, Select, PreEditChange, Submit, } pub struct TextInputData { /// A parley TextEditor instance pub editor: Box>, /// Whether the input is a singleline or multiline input pub is_multiline: bool, /// The scroll offset of the text content within the input, in CSS (unscaled) pixels. /// /// For single-line inputs this is a horizontal offset; for multi-line inputs it is a /// vertical offset. It is kept up to date so that the caret remains visible within the /// input's content box. pub scroll_offset: f32, } // FIXME: Implement Clone for PlainEditor 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, is_multiline, scroll_offset: 0.0, } } pub fn set_text( &mut self, font_ctx: &mut FontContext, layout_ctx: &mut LayoutContext, text: &str, ) { if self.editor.text() != text { self.editor.set_text(text); self.editor.driver(font_ctx, layout_ctx).refresh_layout(); } } /// Recompute [`Self::scroll_offset`] so that the caret stays visible within the input's /// content box. /// /// `content_box_width` and `content_box_height` are the dimensions of the input's content /// box in CSS (unscaled) pixels. pub fn clamp_scroll_offset(&mut self, content_box_width: f32, content_box_height: f32) { let Some(layout) = self.editor.try_layout() else { return; }; // Parley lays out at the editor's scale, so its geometry is in scaled (device) pixels. // We convert into CSS (unscaled) pixels to match `scroll_offset` and the content box. let scale = layout.scale(); // The caret geometry relative to the start of the text content. let Some(caret) = self.editor.cursor_geometry(1.5) else { return; }; // Caret bounds and content/viewport extents along the scrolling axis (CSS pixels). 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; // Scroll so that both edges of the caret are within the visible region. if caret_end > offset + viewport { offset = caret_end - viewport; } if caret_start < offset { offset = caret_start; } // Never scroll past the content, and never scroll into negative space. The content // extent includes the caret so that a caret at the very end remains fully visible // (its rendered width extends slightly past the text). let max_offset = (content.max(caret_end) - viewport).max(0.0); self.scroll_offset = offset.clamp(0.0, max_offset); } /// The maximum valid value of [`Self::scroll_offset`] (in CSS pixels) given the input's /// content box, i.e. the extent by which the text content overflows the content box along /// the input's scroll axis. /// /// `content_box_width` and `content_box_height` are the dimensions of the input's content /// box in CSS (unscaled) pixels. 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) } /// Scroll the input's text content by `delta` CSS pixels along its scroll axis (horizontal /// for single-line inputs, vertical for multi-line inputs), clamping to the scrollable /// range. /// /// Returns the portion of `delta` that could not be consumed (because the input was already /// scrolled to its limit), so the caller can bubble it up to an ancestor scroller. 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; } // Match the sign convention used for block scrolling: a positive delta decreases the // scroll offset. 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, shell_provider: &dyn ShellProvider, event: BlitzKeyEvent, ) -> Option { // Do nothing if it is a keyup event if !event.state.is_pressed() { return None; } let mods = event.modifiers; let shift = mods.contains(Modifiers::SHIFT); let action_mod = mods.contains(ACTION_MOD); let is_multiline = self.is_multiline; let editor = &mut self.editor; let mut driver = editor.driver(font_ctx, layout_ctx); match event.key { Key::Character(c) if action_mod && matches!(c.as_str(), "c" | "x" | "v") => { match c.to_lowercase().as_str() { "c" => { if let Some(text) = driver.editor.selected_text() { let _ = shell_provider.set_clipboard_text(text.to_owned()); } } "x" => { if let Some(text) = driver.editor.selected_text() { let _ = shell_provider.set_clipboard_text(text.to_owned()); driver.delete_selection() } } "v" => { let text = shell_provider.get_clipboard_text().unwrap_or_default(); driver.insert_or_replace_selection(&text) } _ => unreachable!(), } return Some(GeneratedTextInputEvent::Input); } 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::Named(NamedKey::ArrowLeft) => { if action_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::Named(NamedKey::ArrowRight) => { if action_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::Named(NamedKey::ArrowUp) => { if shift { driver.select_up() } else { driver.move_up() } return Some(GeneratedTextInputEvent::Select); } Key::Named(NamedKey::ArrowDown) => { if shift { driver.select_down() } else { driver.move_down() } return Some(GeneratedTextInputEvent::Select); } Key::Named(NamedKey::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::Named(NamedKey::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::Named(NamedKey::Delete) => { if action_mod { driver.delete_word() } else { driver.delete() } return Some(GeneratedTextInputEvent::Input); } // On macOS this is handled by the apple standard keybindings #[cfg(not(target_os = "macos"))] Key::Named(NamedKey::Backspace) => { 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::Named(NamedKey::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::META) => { 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, shell_provider: &dyn ShellProvider, command: &str, ) -> Option { let editor = &mut self.editor; let mut driver = editor.driver(font_ctx, layout_ctx); let is_multiline = self.is_multiline; match command { // Inserting Content // Inserts a backtab character. "insertBacktab:" => {} // Inserts a container break, such as a new page break. "insertContainerBreak:" => {} // Inserts a double quotation mark without substituting a curly quotation mark. "insertDoubleQuoteIgnoringSubstitution:" => { driver.insert_or_replace_selection("\""); return Some(GeneratedTextInputEvent::Input); } // Inserts a line break character. "insertLineBreak:" => { driver.insert_or_replace_selection("\n"); return Some(GeneratedTextInputEvent::Input); } // Inserts a newline character. "insertNewline:" => { if is_multiline { driver.insert_or_replace_selection("\n"); return Some(GeneratedTextInputEvent::Input); } else { return Some(GeneratedTextInputEvent::Submit); } } // Inserts a newline character without invoking the field editor’s normal handling to end editing. "insertNewlineIgnoringFieldEditor:" => { driver.insert_or_replace_selection("\n"); return Some(GeneratedTextInputEvent::Input); } // Inserts a paragraph separator. "insertParagraphSeparator:" => { driver.insert_or_replace_selection("\n"); return Some(GeneratedTextInputEvent::Input); } "insertSingleQuoteIgnoringSubstitution:" => { driver.insert_or_replace_selection("'"); return Some(GeneratedTextInputEvent::Input); } // Inserts a tab character. "insertTab:" | "insertTabIgnoringFieldEditor:" => { // Ignore for now seeing as parley has poor support for laying out tabs } // Inserts the text you specify. "insertText:" => {} // Deleting Content // Deletes content moving backward from the current insertion point. // TODO: handle deleteBackwardByDecomposingPreviousCharacter separately "deleteBackward:" | "deleteBackwardByDecomposingPreviousCharacter:" => { driver.backdelete(); return Some(GeneratedTextInputEvent::Input); } "deleteForward:" => { driver.delete(); return Some(GeneratedTextInputEvent::Input); } // Deletes content from the insertion point to the beginning of the current line. "deleteToBeginningOfLine:" => { if driver.editor.raw_selection().is_collapsed() { driver.select_to_line_start(); } driver.delete_selection(); return Some(GeneratedTextInputEvent::Input); } // Deletes content from the insertion point to the beginning of the current paragraph. "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); } // Deletes content from the insertion point to the end of the current line. "deleteToEndOfParagraph:" => { if driver.editor.raw_selection().is_collapsed() { driver.select_to_hard_line_end(); } driver.delete_selection(); return Some(GeneratedTextInputEvent::Input); } // Deletes content from the insertion point to the end of the current paragraph. "deleteWordBackward:" => { driver.backdelete_word(); return Some(GeneratedTextInputEvent::Input); } // Deletes the word preceding the current insertion point. "deleteWordForward:" => { driver.delete_word(); return Some(GeneratedTextInputEvent::Input); } // Deletes the current selection, placing it in a temporary buffer, such as the Clipboard. "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); } } // Moving the Insertion Pointer // Moves the insertion pointer backward in the current content. "moveBackward:" => { driver.move_left(); // TODO: Bidi-aware return Some(GeneratedTextInputEvent::Select); } // Moves the insertion pointer down in the current content. "moveDown:" => { driver.move_down(); return Some(GeneratedTextInputEvent::Select); } // Moves the insertion pointer forward in the current content. "moveForward:" => { driver.move_right(); return Some(GeneratedTextInputEvent::Select); } // TODO: Bidi-aware // Moves the insertion pointer left in the current content. "moveLeft:" => { driver.move_left(); return Some(GeneratedTextInputEvent::Select); } // Moves the insertion pointer right in the current content. "moveRight:" => { driver.move_right(); return Some(GeneratedTextInputEvent::Select); } // Moves the insertion pointer up in the current content. "moveUp:" => { driver.move_up(); return Some(GeneratedTextInputEvent::Select); } // Modifying the Selection // Extends the selection to include the content before the current selection. "moveBackwardAndModifySelection:" => { driver.select_left(); // TODO: Bidi-aware return Some(GeneratedTextInputEvent::Select); } // Extends the selection to include the content below the current selection. "moveDownAndModifySelection:" => { driver.select_down(); return Some(GeneratedTextInputEvent::Select); } // Extends the selection to include the content after the current selection. "moveForwardAndModifySelection:" => { driver.select_right(); // TODO: Bidi-aware return Some(GeneratedTextInputEvent::Select); } // Extends the selection to include the content to the left of the current selection. "moveLeftAndModifySelection:" => { driver.select_left(); return Some(GeneratedTextInputEvent::Select); } // Extends the selection to include the content to the right of the current selection. "moveRightAndModifySelection:" => { driver.select_right(); return Some(GeneratedTextInputEvent::Select); } // Extends the selection to include the content above the current selection. "moveUpAndModifySelection:" => { driver.select_up(); return Some(GeneratedTextInputEvent::Select); } // Changing the Selection "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:" => { // TODO } // Moving the Selection in Documents "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); } // Moving the Selection in Paragraphs "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); } // Moving the Selection in Lines of Text "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); } // Moving the Selection by Word Boundaries "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); } // Scrolling Content // Scrolls the content down by a page. "scrollPageDown:" => {} // Scrolls the content up by a page. "scrollPageUp:" => {} // Scrolls the content down by a line. "scrollLineDown:" => {} // Scrolls the content up by a line. "scrollLineUp:" => {} // Scrolls the content to the beginning of the document. "scrollToBeginningOfDocument:" => {} // Scrolls the content to the end of the document. "scrollToEndOfDocument:" => {} // Moves the visible content region down by a page. "pageDown:" => {} // Moves the visible content region up by a page. "pageUp:" => {} // Moves the visible content region down by a page, and extends the current selection. "pageDownAndModifySelection:" => {} // Moves the visible content region up by a page, and extends the current selection. "pageUpAndModifySelection:" => {} // Moves the visible content region so the current selection is visually centered. "centerSelectionInVisibleArea:" => {} // Transposing Elements // Transposes the content around the current selection. "transpose:" => {} // Transposes the words around the current selection. "transposeWords:" => {} // Indenting Content // Indents the content at the current selection. "indent:" => {} // Canceling Operations // Cancels the current operation. "cancelOperation:" => {} // Supporting QuickLook // Invokes QuickLook to preview the current selection. "quickLookPreviewItems:" => {} // Supporting Writing Directions "makeBaseWritingDirectionLeftToRight:" => {} "makeBaseWritingDirectionNatural:" => {} "makeBaseWritingDirectionRightToLeft:" => {} "makeTextWritingDirectionLeftToRight:" => {} "makeTextWritingDirectionNatural:" => {} "makeTextWritingDirectionRightToLeft:" => {} // Changing Capitalization "capitalizeWord:" => {} "changeCaseOfLetter:" => {} "lowercaseWord:" => {} "uppercaseWord:" => {} // Supporting Marked Selections "setMark:" => {} "selectToMark:" => {} "deleteToMark:" => {} "swapWithMark:" => {} // Supporting Autocomplete "complete:" => {} // Instance Methods "showContextMenuForSelection:" => {} // Unknown command _ => {} }; None } pub(crate) fn apply_ime_event( &mut self, font_ctx: &mut FontContext, layout_ctx: &mut LayoutContext, event: BlitzImeEvent, ) -> Option { let editor = &mut self.editor; let mut driver = editor.driver(font_ctx, layout_ctx); match event { BlitzImeEvent::Enabled => { // Do nothing 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; // TODO None } } } }