ps-blitz-dom 0.4.7

Blitz DOM implementation
Documentation
use crate::{BaseDocument, node::GeneratedTextInputEvent, util::has_clipboard_modifier};
use blitz_traits::node_id::NodeId;
use blitz_traits::{
    SmolStr,
    events::{BlitzInputEvent, BlitzKeyEvent, BlitzSubmitEvent, DomEvent, DomEventData},
};
use keyboard_types::{Code, Key, Modifiers};
use markup5ever::local_name;

pub(super) enum KeyboardOrTextInputEvent {
    KeyPress(BlitzKeyEvent),
    AppleStandardKeyBinding(SmolStr),
}

/// Whether this keystroke is the Copy chord.
///
/// The physical key is checked first and the character second, so a layout that
/// puts a different character on that key, or a modifier that rewrites it, still
/// copies. Reading only the character is why Cmd+C could silently do nothing.
///
/// The caller has already established that a clipboard modifier is held.
fn is_document_copy(event: &BlitzKeyEvent) -> bool {
    event.code == Code::KeyC
        || matches!(&event.key, Key::Character(c) if c.eq_ignore_ascii_case("c"))
}

pub(crate) fn handle_key_or_input_event<F: FnMut(DomEvent)>(
    doc: &mut BaseDocument,
    target: NodeId,
    event: KeyboardOrTextInputEvent,
    dispatch_event: F,
) {
    if let KeyboardOrTextInputEvent::KeyPress(event) = &event {
        if event.key == Key::Tab {
            if event.modifiers.contains(Modifiers::SHIFT) {
                doc.focus_prev_node();
            } else {
                doc.focus_next_node();
            }
            return;
        }

        /*
         * Copy a document selection: the transcript, a label, anything outside
         * a text input.
         *
         * Matched on the physical `code` as well as the character, the same way
         * `clipboard_command` does for text inputs. Reading only `key` meant a
         * layout that puts something else on that key, or a modifier that
         * rewrites the character, produced no copy at all — the keystroke
         * simply did nothing, with no way to tell it apart from an empty
         * selection.
         *
         * A focused text input no longer suppresses this. It used to: the
         * assumption was that a focused field owns the keystroke, but an app
         * whose composer holds focus permanently — which is the normal state of
         * a chat window — could then never copy from its own transcript, and
         * the selection the user could see highlighted was not what got copied.
         * What decides now is where the selection actually is: a field with a
         * selection of its own keeps the keystroke, and otherwise the document
         * selection is copied.
         */
        if event.state.is_pressed() && has_clipboard_modifier(event.modifiers) {
            if is_document_copy(event) {
                let field_owns_the_keystroke = doc.focus_node_id.is_some_and(|id| {
                    doc.get_node(id)
                        .and_then(|n| n.element_data())
                        .and_then(|e| e.text_input_data())
                        .is_some_and(|input| !input.editor.raw_selection().text_range().is_empty())
                });

                if !field_owns_the_keystroke {
                    if let Some(text) = doc.get_selected_text() {
                        if !text.is_empty() {
                            let _ = doc.shell_provider.set_clipboard_text(text);
                            return;
                        }
                    }
                }
            }
        }
    }

    if let Some(node_id) = doc.focus_node_id {
        if target != node_id {
            return;
        }

        let node = &mut doc.nodes[node_id];
        let Some(element_data) = node.element_data_mut() else {
            return;
        };

        if let Some(input_data) = element_data.text_input_data_mut() {
            let generated_event = match event {
                KeyboardOrTextInputEvent::KeyPress(blitz_key_event) => input_data
                    .apply_keypress_event(
                        &mut doc.font_ctx.lock().unwrap(),
                        &mut doc.layout_ctx,
                        &*doc.shell_provider,
                        blitz_key_event,
                    ),
                KeyboardOrTextInputEvent::AppleStandardKeyBinding(command) => input_data
                    .apply_apple_standard_keybinding(
                        &mut doc.font_ctx.lock().unwrap(),
                        &mut doc.layout_ctx,
                        &*doc.shell_provider,
                        &command,
                    ),
            };

            if let Some(generated_event) = generated_event {
                doc.apply_generated_text_input_event(node_id, generated_event, dispatch_event);
            }
        }
    }
}

impl BaseDocument {
    pub(crate) fn apply_generated_text_input_event<F: FnMut(DomEvent)>(
        &mut self,
        node_id: NodeId,
        event: GeneratedTextInputEvent,
        mut dispatch_event: F,
    ) {
        let node = &mut self.nodes[node_id];
        let element_data = node
            .element_data_mut()
            .expect("apply_generated_text_input_event called on a node that is not an element");
        let input_data = element_data
            .text_input_data_mut()
            .expect("apply_generated_text_input_event called on a node that is not a text input");

        match event {
            GeneratedTextInputEvent::Input => {
                let value = input_data.editor.raw_text().to_string();
                dispatch_event(DomEvent::new(
                    node_id,
                    DomEventData::Input(BlitzInputEvent { value }),
                ));
                self.shell_provider.request_redraw();
            }
            GeneratedTextInputEvent::Select | GeneratedTextInputEvent::PreEditChange => {
                self.shell_provider.request_redraw();
            }
            GeneratedTextInputEvent::Submit => {
                implicit_form_submission(self, node_id, dispatch_event);
            }
        }
    }
}

/// Whether this control is a submit button, and so a candidate for the form's
/// default button.
///
/// <https://html.spec.whatwg.org/multipage/forms.html#default-button>
fn is_submit_button(element_data: &crate::ElementData) -> bool {
    // `ElementData::is_submit_button` covers `<button>`, including the
    // command-attribute cases that take it back out of the Submit Button state.
    element_data.is_submit_button()
        || (element_data.name.local == local_name!("input")
            && matches!(
                element_data.attr(local_name!("type")),
                Some("submit" | "image")
            ))
}

/// <https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#implicit-submission>
fn implicit_form_submission<F: FnMut(DomEvent)>(
    doc: &BaseDocument,
    text_target: NodeId,
    mut dispatch_event: F,
) {
    let Some(&form_owner_id) = doc.controls_to_form.get(&text_target) else {
        return;
    };

    // Walked over the tree rather than over `controls_to_form` directly, for
    // two reasons. Tree order is what names the default button, and the map is
    // not a reliable source of live node ids: a control removed from the
    // document leaves its key behind, so a framework that re-rendered a field
    // left a freed id in it and indexing the slab for that id panicked with
    // "invalid SlotMap key used", taking the host process down. The traverser
    // only yields nodes that are still in the tree.
    let controls: Vec<NodeId> = crate::traversal::TreeTraverser::new(doc)
        .filter(|control_id| doc.controls_to_form.get(control_id) == Some(&form_owner_id))
        .collect();

    // "If the form has a default button, then act as if that button was
    // clicked." The multiple-fields rule below applies only when there is none,
    // which is the whole of the gate: a login form with a user field, a
    // password field and a Sign in button submits on Enter, and this returned
    // early on it instead.
    if let Some(default_button) = controls.iter().copied().find(|control_id| {
        doc.get_node(*control_id)
            .and_then(|node| node.element_data())
            .is_some_and(is_submit_button)
    }) {
        dispatch_event(DomEvent::new(
            form_owner_id,
            DomEventData::Submit(BlitzSubmitEvent {
                form: form_owner_id.as_u64(),
                submitter: default_button.as_u64(),
            }),
        ));
        return;
    }

    if controls
        .iter()
        .filter_map(|control_id| doc.get_node(*control_id))
        .filter_map(|node| node.element_data())
        .filter(|element_data| {
            element_data.attr(local_name!("type")).is_some_and(|t| {
                matches!(
                    t,
                    "text"
                        | "search"
                        | "email"
                        | "url"
                        | "tel"
                        | "password"
                        | "date"
                        | "month"
                        | "week"
                        | "time"
                        | "datetime-local"
                        | "number"
                )
            })
        })
        .count()
        > 1
    {
        return;
    }

    // Dispatched rather than submitted, for the same reason a button press is:
    // pressing Enter in the only text field of a form is a submission a page
    // handles, and a default action nothing can cancel is not one.
    dispatch_event(DomEvent::new(
        form_owner_id,
        DomEventData::Submit(BlitzSubmitEvent {
            form: form_owner_id.as_u64(),
            submitter: form_owner_id.as_u64(),
        }),
    ));
}

/// The Copy chord has to be recognised however the platform reports it.
///
/// Copying from the transcript was unreliable in a way that looked random: the
/// keystroke was matched on the character alone, so anything that changed the
/// character — a non-QWERTY layout, a modifier combination the platform folds
/// into it — produced no copy and no error either.
#[cfg(test)]
mod copy_chord_tests {
    use super::*;
    use blitz_traits::events::KeyState;
    use keyboard_types::Location;

    fn 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 the_plain_character_is_a_copy() {
        assert!(is_document_copy(&event(
            Key::Character("c".into()),
            Code::KeyC
        )));
    }

    /// Ctrl+C arrives as the ETX control character on some platforms, and the
    /// physical key is the only thing left that still says "C".
    #[test]
    fn a_control_character_is_a_copy_by_its_physical_key() {
        assert!(is_document_copy(&event(
            Key::Character("\u{3}".into()),
            Code::KeyC
        )));
    }

    /// A layout that puts another character on the C key still copies.
    #[test]
    fn a_remapped_character_is_a_copy_by_its_physical_key() {
        assert!(is_document_copy(&event(
            Key::Character("ç".into()),
            Code::KeyC
        )));
    }

    /// And the character still counts when the physical key is unknown, which
    /// is what a synthesised or remapped event reports.
    #[test]
    fn an_unidentified_key_is_a_copy_by_its_character() {
        assert!(is_document_copy(&event(
            Key::Character("C".into()),
            Code::Unidentified
        )));
    }

    #[test]
    fn another_key_is_not_a_copy() {
        assert!(!is_document_copy(&event(
            Key::Character("v".into()),
            Code::KeyV
        )));
    }
}