diff --git a/crates/browser/src/main.rs b/crates/browser/src/main.rs index 7a0e98a..28641eb 100644 --- a/crates/browser/src/main.rs +++ b/crates/browser/src/main.rs @@ -302,14 +302,287 @@ fn handle_resize(width: f64, height: f64) { }); } -/// macOS key code for the Tab key. +// --------------------------------------------------------------------------- +// macOS key codes +// --------------------------------------------------------------------------- + const KEY_CODE_TAB: u16 = 48; +const KEY_CODE_DELETE: u16 = 51; // Backspace +const KEY_CODE_FORWARD_DELETE: u16 = 117; +const KEY_CODE_LEFT: u16 = 123; +const KEY_CODE_RIGHT: u16 = 124; +const KEY_CODE_UP: u16 = 126; +const KEY_CODE_DOWN: u16 = 125; +const KEY_CODE_HOME: u16 = 115; +const KEY_CODE_END: u16 = 119; +const KEY_CODE_RETURN: u16 = 36; +const KEY_CODE_A: u16 = 0; +const KEY_CODE_C: u16 = 8; +const KEY_CODE_V: u16 = 9; +const KEY_CODE_X: u16 = 7; + +/// Returns true if the given element is an editable text control. +fn is_text_editable(doc: &we_dom::Document, node: NodeId) -> bool { + match doc.tag_name(node) { + Some("textarea") => true, + Some("input") => { + let t = doc.get_attribute(node, "type").unwrap_or("text"); + matches!( + t, + "text" | "password" | "email" | "url" | "search" | "tel" | "number" + ) + } + _ => false, + } +} + +/// Ensure an InputState exists for `node`, initializing from the DOM value attribute. +fn ensure_input_state(doc: &mut we_dom::Document, node: NodeId) { + let default_value = match doc.tag_name(node) { + Some("textarea") => { + // For textarea, the initial value is the text content. + let mut text = String::new(); + collect_text_content_into(doc, node, &mut text); + text + } + _ => doc.get_attribute(node, "value").unwrap_or("").to_string(), + }; + doc.input_states.get_or_create(node, &default_value); +} + +/// Collect text content of a node (for textarea initial value). +fn collect_text_content_into(doc: &we_dom::Document, node: NodeId, out: &mut String) { + match doc.node_data(node) { + we_dom::NodeData::Text { data } => out.push_str(data), + _ => { + for child in doc.children(node) { + collect_text_content_into(doc, child, out); + } + } + } +} + +/// Re-render the page and mark the view as needing display. +fn rerender(state: &mut BrowserState) { + let viewport_width = state.bitmap.width() as f32; + let viewport_height = state.bitmap.height() as f32; + let content_height = render_page( + &state.page, + &state.font, + &mut state.backend, + &state.view, + &mut state.bitmap, + viewport_width, + viewport_height, + state.page_scroll_y, + &state.scroll_offsets, + ); + state.content_height = content_height; + if let ViewKind::Bitmap(bitmap_view) = &state.view { + bitmap_view.set_needs_display(); + } +} /// Called by the platform crate on key-down events. -fn handle_key_down(key_code: u16, _chars: &str, shift: bool) { +fn handle_key_down(key_code: u16, chars: &str, mods: appkit::KeyModifiers) { if key_code == KEY_CODE_TAB { - handle_tab(shift); + handle_tab(mods.shift); + return; } + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let state = match state.as_mut() { + Some(s) => s, + None => return, + }; + + // Only process editing keys when a text control is focused. + let focused = match state.page.doc.active_element() { + Some(n) => n, + None => return, + }; + if !is_text_editable(&state.page.doc, focused) { + return; + } + + // Ensure InputState is initialized for this element. + ensure_input_state(&mut state.page.doc, focused); + + let mut needs_render = false; + let mut text_changed = false; + + // Cmd+A: select all + if mods.command && key_code == KEY_CODE_A { + if let Some(is) = state.page.doc.input_states.get_mut(focused) { + is.select_all(); + needs_render = true; + } + } + // Cmd+C: copy + else if mods.command && key_code == KEY_CODE_C { + if let Some(is) = state.page.doc.input_states.get(focused) { + if is.has_selection() { + appkit::clipboard_set_string(is.selected_text()); + } + } + } + // Cmd+X: cut + else if mods.command && key_code == KEY_CODE_X { + if let Some(is) = state.page.doc.input_states.get_mut(focused) { + if is.has_selection() { + appkit::clipboard_set_string(is.selected_text()); + is.insert(""); + text_changed = true; + needs_render = true; + } + } + } + // Cmd+V: paste + else if mods.command && key_code == KEY_CODE_V { + if let Some(clipboard) = appkit::clipboard_get_string() { + let is_textarea = state.page.doc.tag_name(focused) == Some("textarea"); + if let Some(is) = state.page.doc.input_states.get_mut(focused) { + // For single-line inputs, strip newlines. + let paste_text = if !is_textarea { + clipboard.replace(['\n', '\r'], "") + } else { + clipboard + }; + if is.insert(&paste_text) { + text_changed = true; + needs_render = true; + } + } + } + } + // Arrow keys + else if key_code == KEY_CODE_LEFT { + if let Some(is) = state.page.doc.input_states.get_mut(focused) { + if mods.command { + is.move_to_line_start(mods.shift); + } else if mods.option { + is.move_word_left(mods.shift); + } else { + is.move_left(mods.shift); + } + needs_render = true; + } + } else if key_code == KEY_CODE_RIGHT { + if let Some(is) = state.page.doc.input_states.get_mut(focused) { + if mods.command { + is.move_to_line_end(mods.shift); + } else if mods.option { + is.move_word_right(mods.shift); + } else { + is.move_right(mods.shift); + } + needs_render = true; + } + } else if key_code == KEY_CODE_UP { + if let Some(is) = state.page.doc.input_states.get_mut(focused) { + if mods.command { + is.move_to_start(mods.shift); + } else { + // For single-line inputs, move to start. + is.move_to_line_start(mods.shift); + } + needs_render = true; + } + } else if key_code == KEY_CODE_DOWN { + if let Some(is) = state.page.doc.input_states.get_mut(focused) { + if mods.command { + is.move_to_end(mods.shift); + } else { + // For single-line inputs, move to end. + is.move_to_line_end(mods.shift); + } + needs_render = true; + } + } + // Home / End + else if key_code == KEY_CODE_HOME { + if let Some(is) = state.page.doc.input_states.get_mut(focused) { + is.move_to_start(mods.shift); + needs_render = true; + } + } else if key_code == KEY_CODE_END { + if let Some(is) = state.page.doc.input_states.get_mut(focused) { + is.move_to_end(mods.shift); + needs_render = true; + } + } + // Backspace / Delete + else if key_code == KEY_CODE_DELETE { + if let Some(is) = state.page.doc.input_states.get_mut(focused) { + let changed = if mods.command { + is.delete_to_line_start() + } else if mods.option { + is.delete_word_backward() + } else { + is.delete_backward() + }; + if changed { + text_changed = true; + needs_render = true; + } + } + } else if key_code == KEY_CODE_FORWARD_DELETE { + if let Some(is) = state.page.doc.input_states.get_mut(focused) { + if is.delete_forward() { + text_changed = true; + needs_render = true; + } + } + } + // Return/Enter + else if key_code == KEY_CODE_RETURN { + if state.page.doc.tag_name(focused) == Some("textarea") { + if let Some(is) = state.page.doc.input_states.get_mut(focused) { + if is.insert("\n") { + text_changed = true; + needs_render = true; + } + } + } + // For single-line inputs, Enter doesn't insert text. + } + // Character input (printable characters, no Cmd modifier). + else if !mods.command && !mods.control { + // Filter out non-printable control characters. + let printable: String = chars + .chars() + .filter(|c| !c.is_control() || *c == '\t') + .collect(); + if !printable.is_empty() { + // For single-line inputs, strip newlines. + let is_textarea = state.page.doc.tag_name(focused) == Some("textarea"); + let insert_text = if !is_textarea { + printable.replace(['\n', '\r'], "") + } else { + printable + }; + if let Some(is) = state.page.doc.input_states.get_mut(focused) { + if is.insert(&insert_text) { + text_changed = true; + needs_render = true; + } + } + } + } + + // Sync edited value back to the DOM `value` attribute. + if text_changed { + if let Some(is) = state.page.doc.input_states.get(focused) { + let new_value = is.text().to_string(); + state.page.doc.set_attribute(focused, "value", &new_value); + } + } + + if needs_render { + rerender(state); + } + }); } /// Advance (or reverse) focus through the document's tab order. @@ -326,6 +599,8 @@ fn handle_tab(shift: bool) { return; } + let old_focus = state.page.doc.active_element(); + let current = state.page.doc.active_element(); let next = match current { Some(cur) => { @@ -359,30 +634,247 @@ fn handle_tab(shift: bool) { } }; + // Initialize input state for the newly focused element. + if is_text_editable(&state.page.doc, next) { + ensure_input_state(&mut state.page.doc, next); + // Select all text on Tab focus. + if let Some(is) = state.page.doc.input_states.get_mut(next) { + is.select_all(); + is.record_focus_value(); + } + } + + // Record focus change (for old element's `change` event). + let _ = old_focus; + state.page.doc.set_active_element(Some(next), true); + rerender(state); + }); +} - // Re-render to show focus ring. +/// Handle mouse-down events — focus text inputs and position cursor. +fn handle_mouse_down(x: f64, y: f64, click_count: u32, _mods: appkit::KeyModifiers) { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let state = match state.as_mut() { + Some(s) => s, + None => return, + }; + + let view_x = x as f32; + let view_y = y as f32 + state.page_scroll_y; + + // Hit-test: find the form control element at (view_x, view_y). let viewport_width = state.bitmap.width() as f32; let viewport_height = state.bitmap.height() as f32; - let content_height = render_page( - &state.page, + + // We need to do layout to find which element was clicked. + // Re-use the last layout by doing a hit-test on the layout tree. + let styled = match we_style::computed::resolve_styles( + &state.page.doc, + std::slice::from_ref(&state.page.stylesheet), + (viewport_width, viewport_height), + ) { + Some(s) => s, + None => return, + }; + + let mut img_sizes = image_sizes(&state.page.images); + let svg_sizes = collect_svg_sizes(&state.page.doc); + img_sizes.extend(svg_sizes); + we_browser::iframe_loader::collect_iframe_sizes(&state.page.doc, &mut img_sizes); + + let tree = we_layout::layout( + &styled, + &state.page.doc, + viewport_width, + viewport_height, &state.font, - &mut state.backend, - &state.view, - &mut state.bitmap, + &img_sizes, + ); + + // Hit-test the layout tree. + if let Some((node, local_x, _content_width, font_size)) = + hit_test_form_control(&tree.root, view_x, view_y, 0.0, 0.0) + { + // Focus the clicked element. + let was_focused = state.page.doc.active_element() == Some(node); + state.page.doc.set_active_element(Some(node), false); + + if is_text_editable(&state.page.doc, node) { + ensure_input_state(&mut state.page.doc, node); + + if !was_focused { + // First click to focus — select all and record focus value. + if let Some(is) = state.page.doc.input_states.get_mut(node) { + is.select_all(); + is.record_focus_value(); + } + } else if click_count >= 2 { + // Double-click: select word at cursor position. + let char_width = font_size * 0.6; + let char_idx = if char_width > 0.0 { + (local_x / char_width) as usize + } else { + 0 + }; + if let Some(is) = state.page.doc.input_states.get_mut(node) { + let byte_pos = char_to_byte_offset(is.text(), char_idx); + is.select_word_at(byte_pos); + } + } else { + // Single click: position cursor. + let char_width = font_size * 0.6; + let char_idx = if char_width > 0.0 { + (local_x / char_width).round() as usize + } else { + 0 + }; + if let Some(is) = state.page.doc.input_states.get_mut(node) { + let byte_pos = char_to_byte_offset(is.text(), char_idx); + is.set_cursor(byte_pos); + } + } + } + rerender(state); + } else { + // Clicked outside any form control — blur. + if state.page.doc.active_element().is_some() { + state.page.doc.set_active_element(None, false); + rerender(state); + } + } + }); +} + +/// Handle mouse-dragged events — extend text selection. +fn handle_mouse_dragged(x: f64, y: f64) { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let state = match state.as_mut() { + Some(s) => s, + None => return, + }; + + let focused = match state.page.doc.active_element() { + Some(n) if is_text_editable(&state.page.doc, n) => n, + _ => return, + }; + + let view_x = x as f32; + let view_y = y as f32 + state.page_scroll_y; + let viewport_width = state.bitmap.width() as f32; + let viewport_height = state.bitmap.height() as f32; + + let styled = match we_style::computed::resolve_styles( + &state.page.doc, + std::slice::from_ref(&state.page.stylesheet), + (viewport_width, viewport_height), + ) { + Some(s) => s, + None => return, + }; + + let mut img_sizes = image_sizes(&state.page.images); + let svg_sizes = collect_svg_sizes(&state.page.doc); + img_sizes.extend(svg_sizes); + we_browser::iframe_loader::collect_iframe_sizes(&state.page.doc, &mut img_sizes); + + let tree = we_layout::layout( + &styled, + &state.page.doc, viewport_width, viewport_height, - state.page_scroll_y, - &state.scroll_offsets, + &state.font, + &img_sizes, ); - state.content_height = content_height; - if let ViewKind::Bitmap(bitmap_view) = &state.view { - bitmap_view.set_needs_display(); + if let Some((_, local_x, _, font_size)) = + hit_test_form_control(&tree.root, view_x, view_y, 0.0, 0.0) + { + let char_width = font_size * 0.6; + let char_idx = if char_width > 0.0 { + (local_x / char_width).round() as usize + } else { + 0 + }; + if let Some(is) = state.page.doc.input_states.get_mut(focused) { + let byte_pos = char_to_byte_offset(is.text(), char_idx); + is.extend_selection(byte_pos); + } + rerender(state); } }); } +/// Convert a character index to a byte offset in a string. +fn char_to_byte_offset(s: &str, char_idx: usize) -> usize { + s.char_indices() + .nth(char_idx) + .map(|(i, _)| i) + .unwrap_or(s.len()) +} + +/// Hit-test the layout tree for a form control at the given coordinates. +/// +/// Returns `(NodeId, local_x_in_content, content_width, font_size)` if a +/// text-editable form control was hit. +fn hit_test_form_control( + layout_box: &we_layout::LayoutBox, + x: f32, + y: f32, + parent_x: f32, + parent_y: f32, +) -> Option<(NodeId, f32, f32, f32)> { + let bx = parent_x + layout_box.rect.x - layout_box.padding.left - layout_box.border.left; + let by = parent_y + layout_box.rect.y - layout_box.padding.top - layout_box.border.top; + let bw = layout_box.rect.width + + layout_box.padding.left + + layout_box.padding.right + + layout_box.border.left + + layout_box.border.right; + let bh = layout_box.rect.height + + layout_box.padding.top + + layout_box.padding.bottom + + layout_box.border.top + + layout_box.border.bottom; + + // Check if point is within this box. + if x >= bx && x < bx + bw && y >= by && y < by + bh { + // Check children first (front-to-back). + for child in layout_box.children.iter().rev() { + if let Some(hit) = hit_test_form_control( + child, + x, + y, + parent_x + layout_box.rect.x, + parent_y + layout_box.rect.y, + ) { + return Some(hit); + } + } + + // If this is a form control, return it. + if let Some(ref fc) = layout_box.form_control { + if matches!( + fc.control_type, + we_layout::FormControlType::TextInput + | we_layout::FormControlType::Password + | we_layout::FormControlType::Textarea + ) { + if let we_layout::BoxType::Block(node) | we_layout::BoxType::Inline(node) = + layout_box.box_type + { + let content_x = parent_x + layout_box.rect.x; + let local_x = (x - content_x).max(0.0); + return Some((node, local_x, layout_box.rect.width, layout_box.font_size)); + } + } + } + } + None +} + /// Called by the platform crate on scroll wheel events. fn handle_scroll(_dx: f64, dy: f64, _mouse_x: f64, _mouse_y: f64) { STATE.with(|state| { @@ -699,10 +1191,12 @@ fn main() { }); }); - // Register resize, scroll, and key handlers. + // Register resize, scroll, key, and mouse handlers. appkit::set_resize_handler(handle_resize); appkit::set_scroll_handler(handle_scroll); appkit::set_key_handler(handle_key_down); + appkit::set_mouse_down_handler(handle_mouse_down); + appkit::set_mouse_dragged_handler(handle_mouse_dragged); window.make_key_and_order_front(); app.activate(); diff --git a/crates/dom/src/input_state.rs b/crates/dom/src/input_state.rs new file mode 100644 index 0000000..f1281a2 --- /dev/null +++ b/crates/dom/src/input_state.rs @@ -0,0 +1,775 @@ +//! Editable text state for form controls (text inputs, textareas). +//! +//! Tracks cursor position, selection range, and the edited text buffer +//! independently from the DOM `value` attribute. The `value` attribute +//! is the *default* value; this module tracks the *current* value as +//! modified by user interaction. + +use std::collections::HashMap; + +use crate::NodeId; + +/// Selection direction for tracking how the selection was created. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SelectionDirection { + /// Selection was created by moving forward (left to right). + Forward, + /// Selection was created by moving backward (right to left). + Backward, + /// No directional bias (e.g., select-all). + None, +} + +/// The editing state for a single text input or textarea. +#[derive(Debug, Clone)] +pub struct InputState { + /// The current text content (may differ from the DOM attribute). + text: String, + /// Cursor position as a byte offset into `text`. + /// When there is no selection, this is the caret position. + /// When there is a selection, this is the "active" end. + cursor: usize, + /// Selection anchor (the "fixed" end of the selection). + /// When equal to `cursor`, there is no selection. + anchor: usize, + /// Direction the selection was created in. + pub direction: SelectionDirection, + /// The value at the time the element last received focus, + /// used to determine whether to fire a `change` event on blur. + value_on_focus: String, + /// Whether the text has been modified since last sync to DOM. + dirty: bool, +} + +impl InputState { + /// Create a new input state with the given initial text. + pub fn new(text: &str) -> Self { + let len = text.len(); + InputState { + text: text.to_string(), + cursor: len, + anchor: len, + direction: SelectionDirection::None, + value_on_focus: text.to_string(), + dirty: false, + } + } + + /// The current text value. + pub fn text(&self) -> &str { + &self.text + } + + /// The cursor (caret) byte position. + pub fn cursor(&self) -> usize { + self.cursor + } + + /// The anchor byte position (start of selection or same as cursor if no selection). + pub fn anchor(&self) -> usize { + self.anchor + } + + /// Whether there is a text selection (cursor != anchor). + pub fn has_selection(&self) -> bool { + self.cursor != self.anchor + } + + /// Returns (start, end) of the selection range in byte offsets. + /// `start <= end` always holds. + pub fn selection_range(&self) -> (usize, usize) { + if self.cursor <= self.anchor { + (self.cursor, self.anchor) + } else { + (self.anchor, self.cursor) + } + } + + /// Move cursor to a position, collapsing the selection. + pub fn set_cursor(&mut self, pos: usize) { + let pos = pos.min(self.text.len()); + // Snap to char boundary. + let pos = snap_to_char_boundary(&self.text, pos); + self.cursor = pos; + self.anchor = pos; + self.direction = SelectionDirection::None; + } + + /// Move cursor to a position, extending the selection from the anchor. + pub fn extend_selection(&mut self, pos: usize) { + let pos = pos.min(self.text.len()); + let pos = snap_to_char_boundary(&self.text, pos); + self.cursor = pos; + self.direction = if self.cursor >= self.anchor { + SelectionDirection::Forward + } else { + SelectionDirection::Backward + }; + } + + /// Select the entire text. + pub fn select_all(&mut self) { + self.anchor = 0; + self.cursor = self.text.len(); + self.direction = SelectionDirection::Forward; + } + + /// Insert text at the cursor position (or replace the selection). + /// Returns true if the text changed. + pub fn insert(&mut self, s: &str) -> bool { + if s.is_empty() && !self.has_selection() { + return false; + } + let (start, end) = self.selection_range(); + self.text.replace_range(start..end, s); + let new_pos = start + s.len(); + self.cursor = new_pos; + self.anchor = new_pos; + self.direction = SelectionDirection::None; + self.dirty = true; + true + } + + /// Delete the character before the cursor (Backspace). + /// If there is a selection, delete the selection instead. + /// Returns true if the text changed. + pub fn delete_backward(&mut self) -> bool { + if self.has_selection() { + return self.insert(""); + } + if self.cursor == 0 { + return false; + } + let prev = prev_char_boundary(&self.text, self.cursor); + self.text.replace_range(prev..self.cursor, ""); + self.cursor = prev; + self.anchor = prev; + self.dirty = true; + true + } + + /// Delete the character after the cursor (Forward Delete). + /// If there is a selection, delete the selection instead. + /// Returns true if the text changed. + pub fn delete_forward(&mut self) -> bool { + if self.has_selection() { + return self.insert(""); + } + if self.cursor >= self.text.len() { + return false; + } + let next = next_char_boundary(&self.text, self.cursor); + self.text.replace_range(self.cursor..next, ""); + self.dirty = true; + true + } + + /// Delete the word before the cursor (Option+Backspace on macOS). + /// Returns true if the text changed. + pub fn delete_word_backward(&mut self) -> bool { + if self.has_selection() { + return self.insert(""); + } + if self.cursor == 0 { + return false; + } + let word_start = prev_word_boundary(&self.text, self.cursor); + self.text.replace_range(word_start..self.cursor, ""); + self.cursor = word_start; + self.anchor = word_start; + self.dirty = true; + true + } + + /// Delete from cursor to the beginning of the line (Cmd+Backspace on macOS). + /// Returns true if the text changed. + pub fn delete_to_line_start(&mut self) -> bool { + if self.has_selection() { + return self.insert(""); + } + if self.cursor == 0 { + return false; + } + let line_start = line_start_offset(&self.text, self.cursor); + self.text.replace_range(line_start..self.cursor, ""); + self.cursor = line_start; + self.anchor = line_start; + self.dirty = true; + true + } + + /// Move cursor one character to the left. + pub fn move_left(&mut self, extend: bool) { + if !extend && self.has_selection() { + let (start, _) = self.selection_range(); + self.set_cursor(start); + return; + } + if self.cursor > 0 { + let new_pos = prev_char_boundary(&self.text, self.cursor); + if extend { + self.extend_selection(new_pos); + } else { + self.set_cursor(new_pos); + } + } + } + + /// Move cursor one character to the right. + pub fn move_right(&mut self, extend: bool) { + if !extend && self.has_selection() { + let (_, end) = self.selection_range(); + self.set_cursor(end); + return; + } + if self.cursor < self.text.len() { + let new_pos = next_char_boundary(&self.text, self.cursor); + if extend { + self.extend_selection(new_pos); + } else { + self.set_cursor(new_pos); + } + } + } + + /// Move cursor one word to the left (Option+Left on macOS). + pub fn move_word_left(&mut self, extend: bool) { + let new_pos = prev_word_boundary(&self.text, self.cursor); + if extend { + self.extend_selection(new_pos); + } else { + self.set_cursor(new_pos); + } + } + + /// Move cursor one word to the right (Option+Right on macOS). + pub fn move_word_right(&mut self, extend: bool) { + let new_pos = next_word_boundary(&self.text, self.cursor); + if extend { + self.extend_selection(new_pos); + } else { + self.set_cursor(new_pos); + } + } + + /// Move cursor to the start of the line (Home or Cmd+Left on macOS). + pub fn move_to_line_start(&mut self, extend: bool) { + let line_start = line_start_offset(&self.text, self.cursor); + if extend { + self.extend_selection(line_start); + } else { + self.set_cursor(line_start); + } + } + + /// Move cursor to the end of the line (End or Cmd+Right on macOS). + pub fn move_to_line_end(&mut self, extend: bool) { + let line_end = line_end_offset(&self.text, self.cursor); + if extend { + self.extend_selection(line_end); + } else { + self.set_cursor(line_end); + } + } + + /// Move cursor to the very start of the text (Cmd+Up on macOS). + pub fn move_to_start(&mut self, extend: bool) { + if extend { + self.extend_selection(0); + } else { + self.set_cursor(0); + } + } + + /// Move cursor to the very end of the text (Cmd+Down on macOS). + pub fn move_to_end(&mut self, extend: bool) { + let end = self.text.len(); + if extend { + self.extend_selection(end); + } else { + self.set_cursor(end); + } + } + + /// Get the currently selected text, or empty string if no selection. + pub fn selected_text(&self) -> &str { + let (start, end) = self.selection_range(); + &self.text[start..end] + } + + /// Record the current value as the "on focus" value. + /// Called when the element gains focus. + pub fn record_focus_value(&mut self) { + self.value_on_focus = self.text.clone(); + } + + /// Whether the value has changed since the element gained focus. + /// Used to decide whether to fire a `change` event on blur. + pub fn changed_since_focus(&self) -> bool { + self.text != self.value_on_focus + } + + /// Whether the text has been modified since last `clear_dirty()`. + pub fn is_dirty(&self) -> bool { + self.dirty + } + + /// Clear the dirty flag. + pub fn clear_dirty(&mut self) { + self.dirty = false; + } + + /// Replace the entire text content (e.g., from JS `input.value = ...`). + pub fn set_text(&mut self, text: &str) { + self.text = text.to_string(); + // Clamp cursor/anchor to new length. + let len = self.text.len(); + self.cursor = self.cursor.min(len); + self.anchor = self.anchor.min(len); + } + + /// Select the word at the given byte position (double-click). + pub fn select_word_at(&mut self, pos: usize) { + let pos = snap_to_char_boundary(&self.text, pos.min(self.text.len())); + // Find the word boundaries around `pos`. If `pos` is on a word char, + // select the word. Otherwise, select the run of non-word chars. + let at_word = pos < self.text.len() && is_word_char(char_at_byte(&self.text, pos)); + if at_word { + self.anchor = word_start(&self.text, pos); + self.cursor = word_end(&self.text, pos); + } else if pos > 0 { + // Check if the previous char is a word char. + let prev = prev_char_boundary(&self.text, pos); + if is_word_char(char_at_byte(&self.text, prev)) { + self.anchor = word_start(&self.text, prev); + self.cursor = word_end(&self.text, prev); + } else { + // On non-word chars: just place cursor. + self.anchor = pos; + self.cursor = pos; + } + } else { + self.anchor = 0; + self.cursor = 0; + } + self.direction = SelectionDirection::Forward; + } +} + +/// Manages input state for all editable form controls in a document. +#[derive(Debug, Default)] +pub struct InputStateMap { + states: HashMap, +} + +impl InputStateMap { + pub fn new() -> Self { + InputStateMap { + states: HashMap::new(), + } + } + + /// Get or create the input state for a node. + /// `default_text` is used if the state doesn't exist yet. + pub fn get_or_create(&mut self, node: NodeId, default_text: &str) -> &mut InputState { + self.states + .entry(node) + .or_insert_with(|| InputState::new(default_text)) + } + + /// Get the input state for a node, if it exists. + pub fn get(&self, node: NodeId) -> Option<&InputState> { + self.states.get(&node) + } + + /// Get a mutable reference to the input state for a node. + pub fn get_mut(&mut self, node: NodeId) -> Option<&mut InputState> { + self.states.get_mut(&node) + } +} + +// --------------------------------------------------------------------------- +// Text navigation helpers +// --------------------------------------------------------------------------- + +/// Snap a byte offset to the nearest valid char boundary (rounding down). +fn snap_to_char_boundary(s: &str, pos: usize) -> usize { + if pos >= s.len() { + return s.len(); + } + let mut p = pos; + while p > 0 && !s.is_char_boundary(p) { + p -= 1; + } + p +} + +/// Find the previous char boundary before `pos`. +fn prev_char_boundary(s: &str, pos: usize) -> usize { + if pos == 0 { + return 0; + } + let mut p = pos - 1; + while p > 0 && !s.is_char_boundary(p) { + p -= 1; + } + p +} + +/// Find the next char boundary after `pos`. +fn next_char_boundary(s: &str, pos: usize) -> usize { + if pos >= s.len() { + return s.len(); + } + let mut p = pos + 1; + while p < s.len() && !s.is_char_boundary(p) { + p += 1; + } + p +} + +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +/// Find the start of the word before `pos`. +fn prev_word_boundary(s: &str, pos: usize) -> usize { + if pos == 0 { + return 0; + } + let bytes = s.as_bytes(); + let mut p = prev_char_boundary(s, pos); + + // Skip non-word characters. + while p > 0 && !is_word_char(char_at_byte(s, p)) { + p = prev_char_boundary(s, p); + } + // Skip word characters. + while p > 0 { + let prev = prev_char_boundary(s, p); + if !is_word_char(char_at_byte(s, prev)) { + break; + } + p = prev; + } + // Edge case: if we're at byte 0 and it's a word char, stay there. + if p == 0 && !bytes.is_empty() && is_word_char(bytes[0] as char) { + return 0; + } + p +} + +/// Find the end of the word after `pos`. +fn next_word_boundary(s: &str, pos: usize) -> usize { + let len = s.len(); + if pos >= len { + return len; + } + let mut p = pos; + + // Skip word characters. + while p < len { + let c = char_at_byte(s, p); + if !is_word_char(c) { + break; + } + p = next_char_boundary(s, p); + } + // Skip non-word characters. + while p < len { + let c = char_at_byte(s, p); + if is_word_char(c) { + break; + } + p = next_char_boundary(s, p); + } + p +} + +/// Get the char at a byte offset (assumes valid boundary). +fn char_at_byte(s: &str, pos: usize) -> char { + s[pos..].chars().next().unwrap_or('\0') +} + +/// Find the start of the word containing `pos` (scan backward over word chars). +fn word_start(s: &str, pos: usize) -> usize { + let mut p = pos; + while p > 0 { + let prev = prev_char_boundary(s, p); + if !is_word_char(char_at_byte(s, prev)) { + break; + } + p = prev; + } + p +} + +/// Find the end of the word containing `pos` (scan forward over word chars). +fn word_end(s: &str, pos: usize) -> usize { + let mut p = pos; + while p < s.len() { + let c = char_at_byte(s, p); + if !is_word_char(c) { + break; + } + p = next_char_boundary(s, p); + } + p +} + +/// Find the start of the current line (backwards to the previous '\n' or 0). +fn line_start_offset(s: &str, pos: usize) -> usize { + let bytes = s.as_bytes(); + let mut p = pos; + while p > 0 { + if bytes[p - 1] == b'\n' { + return p; + } + p -= 1; + } + 0 +} + +/// Find the end of the current line (forward to the next '\n' or end). +fn line_end_offset(s: &str, pos: usize) -> usize { + let bytes = s.as_bytes(); + let mut p = pos; + while p < bytes.len() { + if bytes[p] == b'\n' { + return p; + } + p += 1; + } + s.len() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_state_cursor_at_end() { + let state = InputState::new("hello"); + assert_eq!(state.cursor(), 5); + assert_eq!(state.anchor(), 5); + assert!(!state.has_selection()); + } + + #[test] + fn insert_at_cursor() { + let mut state = InputState::new("hello"); + state.set_cursor(5); + state.insert(" world"); + assert_eq!(state.text(), "hello world"); + assert_eq!(state.cursor(), 11); + } + + #[test] + fn insert_replaces_selection() { + let mut state = InputState::new("hello world"); + state.set_cursor(0); + state.extend_selection(5); + state.insert("hi"); + assert_eq!(state.text(), "hi world"); + assert_eq!(state.cursor(), 2); + } + + #[test] + fn delete_backward() { + let mut state = InputState::new("hello"); + state.set_cursor(5); + state.delete_backward(); + assert_eq!(state.text(), "hell"); + assert_eq!(state.cursor(), 4); + } + + #[test] + fn delete_backward_at_start() { + let mut state = InputState::new("hello"); + state.set_cursor(0); + assert!(!state.delete_backward()); + assert_eq!(state.text(), "hello"); + } + + #[test] + fn delete_forward() { + let mut state = InputState::new("hello"); + state.set_cursor(0); + state.delete_forward(); + assert_eq!(state.text(), "ello"); + assert_eq!(state.cursor(), 0); + } + + #[test] + fn delete_forward_at_end() { + let mut state = InputState::new("hello"); + state.set_cursor(5); + assert!(!state.delete_forward()); + assert_eq!(state.text(), "hello"); + } + + #[test] + fn delete_selection() { + let mut state = InputState::new("hello world"); + state.set_cursor(5); + state.extend_selection(11); + state.delete_backward(); + assert_eq!(state.text(), "hello"); + } + + #[test] + fn move_left_right() { + let mut state = InputState::new("hello"); + state.set_cursor(3); + state.move_left(false); + assert_eq!(state.cursor(), 2); + state.move_right(false); + assert_eq!(state.cursor(), 3); + } + + #[test] + fn move_left_collapses_selection() { + let mut state = InputState::new("hello"); + state.set_cursor(1); + state.extend_selection(4); + state.move_left(false); + assert_eq!(state.cursor(), 1); + assert!(!state.has_selection()); + } + + #[test] + fn move_right_collapses_selection() { + let mut state = InputState::new("hello"); + state.set_cursor(1); + state.extend_selection(4); + state.move_right(false); + assert_eq!(state.cursor(), 4); + assert!(!state.has_selection()); + } + + #[test] + fn select_all() { + let mut state = InputState::new("hello"); + state.select_all(); + assert!(state.has_selection()); + assert_eq!(state.selection_range(), (0, 5)); + assert_eq!(state.selected_text(), "hello"); + } + + #[test] + fn extend_selection_left() { + let mut state = InputState::new("hello"); + state.set_cursor(3); + state.move_left(true); + assert!(state.has_selection()); + assert_eq!(state.selection_range(), (2, 3)); + assert_eq!(state.selected_text(), "l"); + } + + #[test] + fn word_navigation() { + let mut state = InputState::new("hello world foo"); + state.set_cursor(0); + state.move_word_right(false); + // After "hello" + whitespace, should be at start of "world" + assert_eq!(state.cursor(), 6); + state.move_word_right(false); + assert_eq!(state.cursor(), 12); + state.move_word_left(false); + assert_eq!(state.cursor(), 6); + } + + #[test] + fn delete_word_backward() { + let mut state = InputState::new("hello world"); + state.set_cursor(11); + state.delete_word_backward(); + assert_eq!(state.text(), "hello "); + } + + #[test] + fn line_navigation() { + let mut state = InputState::new("line1\nline2\nline3"); + state.set_cursor(8); // middle of "line2" + state.move_to_line_start(false); + assert_eq!(state.cursor(), 6); // start of "line2" + state.move_to_line_end(false); + assert_eq!(state.cursor(), 11); // end of "line2" (before \n) + } + + #[test] + fn move_to_start_end() { + let mut state = InputState::new("hello world"); + state.set_cursor(5); + state.move_to_start(false); + assert_eq!(state.cursor(), 0); + state.move_to_end(false); + assert_eq!(state.cursor(), 11); + } + + #[test] + fn delete_to_line_start() { + let mut state = InputState::new("hello world"); + state.set_cursor(5); + state.delete_to_line_start(); + assert_eq!(state.text(), " world"); + assert_eq!(state.cursor(), 0); + } + + #[test] + fn select_word_at() { + let mut state = InputState::new("hello world"); + state.select_word_at(3); // in the middle of "hello" + assert_eq!(state.selected_text(), "hello"); + } + + #[test] + fn change_detection() { + let mut state = InputState::new("hello"); + state.record_focus_value(); + assert!(!state.changed_since_focus()); + state.set_cursor(5); + state.insert("!"); + assert!(state.changed_since_focus()); + } + + #[test] + fn unicode_navigation() { + let mut state = InputState::new("héllo"); + state.set_cursor(0); + state.move_right(false); + assert_eq!(state.cursor(), 1); // 'h' is 1 byte + state.move_right(false); + assert_eq!(state.cursor(), 3); // 'é' is 2 bytes + state.move_left(false); + assert_eq!(state.cursor(), 1); + } + + #[test] + fn input_state_map_get_or_create() { + let mut map = InputStateMap::new(); + let node = NodeId::from_index(42); + { + let state = map.get_or_create(node, "default"); + assert_eq!(state.text(), "default"); + state.set_cursor(0); + state.insert("new "); + } + // Subsequent get_or_create should return existing state, not reset. + let state = map.get_or_create(node, "default"); + assert_eq!(state.text(), "new default"); + } + + #[test] + fn set_text_clamps_cursor() { + let mut state = InputState::new("long text here"); + state.set_cursor(14); + state.set_text("hi"); + assert_eq!(state.cursor(), 2); + assert_eq!(state.text(), "hi"); + } +} diff --git a/crates/dom/src/lib.rs b/crates/dom/src/lib.rs index bd7e7e7..494aa09 100644 --- a/crates/dom/src/lib.rs +++ b/crates/dom/src/lib.rs @@ -6,10 +6,14 @@ //! Tag names and attribute names are interned via `Atom` for memory efficiency: //! thousands of `
` elements share one string allocation instead of one each. +pub mod input_state; + use std::fmt; use we_memory::intern::Atom; +pub use input_state::{InputState, InputStateMap}; + /// A handle to a node in the DOM tree. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct NodeId(usize); @@ -74,6 +78,8 @@ pub struct Document { active_element: Option, /// Whether focus was set via keyboard navigation (for `:focus-visible`). focus_visible: bool, + /// Per-element editing state for text inputs and textareas. + pub input_states: InputStateMap, } impl fmt::Debug for Document { @@ -101,6 +107,7 @@ impl Document { root: NodeId(0), active_element: None, focus_visible: false, + input_states: InputStateMap::new(), } } @@ -606,7 +613,12 @@ impl Document { let mut zero_or_natural: Vec<(usize, NodeId)> = Vec::new(); let mut dom_order = 0usize; - self.collect_tab_order(self.root, &mut dom_order, &mut positive, &mut zero_or_natural); + self.collect_tab_order( + self.root, + &mut dom_order, + &mut positive, + &mut zero_or_natural, + ); // Sort positive tabindex by (tabindex, dom_order). positive.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); diff --git a/crates/js/src/dom_bridge.rs b/crates/js/src/dom_bridge.rs index 9f1f085..2a89b15 100644 --- a/crates/js/src/dom_bridge.rs +++ b/crates/js/src/dom_bridge.rs @@ -969,10 +969,7 @@ fn element_blur(_args: &[Value], ctx: &mut NativeContext) -> Result fn build_form_control_info(node: NodeId, doc: &Document) -> Option { let tag = doc.tag_name(node)?; let focused = doc.active_element() == Some(node); + // For editable text controls, prefer the InputState's text over the DOM attribute. + let input_state = doc.input_states.get(node); match tag { "input" => { let input_type = doc.get_attribute(node, "type").unwrap_or("text"); @@ -663,6 +669,8 @@ fn build_form_control_info(node: NodeId, doc: &Document) -> Option Some(FormControlInfo { control_type: FormControlType::Radio, @@ -670,6 +678,8 @@ fn build_form_control_info(node: NodeId, doc: &Document) -> Option { let value = doc @@ -682,6 +692,8 @@ fn build_form_control_info(node: NodeId, doc: &Document) -> Option { @@ -695,6 +707,8 @@ fn build_form_control_info(node: NodeId, doc: &Document) -> Option { @@ -705,40 +719,66 @@ fn build_form_control_info(node: NodeId, doc: &Document) -> Option { - let value = doc.get_attribute(node, "value").unwrap_or("").to_string(); + let (value, cursor, anchor) = if let Some(is) = input_state { + (is.text().to_string(), is.cursor(), is.anchor()) + } else { + let v = doc.get_attribute(node, "value").unwrap_or("").to_string(); + let len = v.len(); + (v, len, len) + }; Some(FormControlInfo { control_type: FormControlType::Password, value, checked: false, disabled, focused, + cursor, + selection_anchor: anchor, }) } // text, email, url, search, tel, number, etc. _ => { - let value = doc.get_attribute(node, "value").unwrap_or("").to_string(); + let (value, cursor, anchor) = if let Some(is) = input_state { + (is.text().to_string(), is.cursor(), is.anchor()) + } else { + let v = doc.get_attribute(node, "value").unwrap_or("").to_string(); + let len = v.len(); + (v, len, len) + }; Some(FormControlInfo { control_type: FormControlType::TextInput, value, checked: false, disabled, focused, + cursor, + selection_anchor: anchor, }) } } } "textarea" => { let disabled = doc.get_attribute(node, "disabled").is_some(); - let value = collect_text_content(doc, node); + let (value, cursor, anchor) = if let Some(is) = input_state { + (is.text().to_string(), is.cursor(), is.anchor()) + } else { + let v = collect_text_content(doc, node); + let len = v.len(); + (v, len, len) + }; Some(FormControlInfo { control_type: FormControlType::Textarea, value, checked: false, disabled, focused, + cursor, + selection_anchor: anchor, }) } "select" => { @@ -751,6 +791,8 @@ fn build_form_control_info(node: NodeId, doc: &Document) -> Option { @@ -767,6 +809,8 @@ fn build_form_control_info(node: NodeId, doc: &Document) -> Option None, diff --git a/crates/platform/src/appkit.rs b/crates/platform/src/appkit.rs index 9b666c9..9000dbb 100644 --- a/crates/platform/src/appkit.rs +++ b/crates/platform/src/appkit.rs @@ -272,39 +272,58 @@ extern "C" fn view_key_down(_this: *mut c_void, _sel: *mut c_void, event: *mut c let c_str = unsafe { CStr::from_ptr(utf8) }; let key_code: u16 = msg_send![event, keyCode]; let modifier_flags: u64 = msg_send![event, modifierFlags]; - let shift = (modifier_flags & 0x20000) != 0; // NSEventModifierFlagShift + let modifiers = KeyModifiers::from_flags(modifier_flags); if let Ok(s) = c_str.to_str() { // SAFETY: We are on the main thread (AppKit event loop). unsafe { if let Some(handler) = KEY_HANDLER { - handler(key_code, s, shift); + handler(key_code, s, modifiers); } } } } -/// `mouseDown:` — log mouse location to stdout. +/// `mouseDown:` — dispatch mouse-down event to registered handler. extern "C" fn view_mouse_down(this: *mut c_void, _sel: *mut c_void, event: *mut c_void) { let raw_loc: NSPoint = msg_send![event, locationInWindow]; let loc: NSPoint = msg_send![this, convertPoint: raw_loc, fromView: std::ptr::null_mut::()]; - println!("mouseDown: ({:.1}, {:.1})", loc.x, loc.y); + let click_count: u64 = msg_send![event, clickCount]; + let modifier_flags: u64 = msg_send![event, modifierFlags]; + let modifiers = KeyModifiers::from_flags(modifier_flags); + // SAFETY: We are on the main thread (AppKit event loop). + unsafe { + if let Some(handler) = MOUSE_DOWN_HANDLER { + handler(loc.x, loc.y, click_count as u32, modifiers); + } + } } -/// `mouseUp:` — log mouse location to stdout. +/// `mouseUp:` — dispatch mouse-up event to registered handler. extern "C" fn view_mouse_up(this: *mut c_void, _sel: *mut c_void, event: *mut c_void) { let raw_loc: NSPoint = msg_send![event, locationInWindow]; - let loc: NSPoint = + let _loc: NSPoint = msg_send![this, convertPoint: raw_loc, fromView: std::ptr::null_mut::()]; - println!("mouseUp: ({:.1}, {:.1})", loc.x, loc.y); } -/// `mouseMoved:` — log mouse location to stdout. +/// `mouseMoved:` — no-op (needed for `acceptsMouseMovedEvents`). extern "C" fn view_mouse_moved(this: *mut c_void, _sel: *mut c_void, event: *mut c_void) { + let raw_loc: NSPoint = msg_send![event, locationInWindow]; + let _loc: NSPoint = + msg_send![this, convertPoint: raw_loc, fromView: std::ptr::null_mut::()]; +} + +/// `mouseDragged:` — dispatch mouse-drag event to registered handler. +extern "C" fn view_mouse_dragged(this: *mut c_void, _sel: *mut c_void, event: *mut c_void) { let raw_loc: NSPoint = msg_send![event, locationInWindow]; let loc: NSPoint = msg_send![this, convertPoint: raw_loc, fromView: std::ptr::null_mut::()]; - println!("mouseMoved: ({:.1}, {:.1})", loc.x, loc.y); + // SAFETY: We are on the main thread (AppKit event loop). + unsafe { + if let Some(handler) = MOUSE_DRAGGED_HANDLER { + handler(loc.x, loc.y); + } + } } /// `scrollWheel:` — call scroll handler with delta and mouse location. @@ -375,6 +394,13 @@ fn register_view_event_handlers(view_class: &Class) { unsafe { std::mem::transmute::<*const (), Imp>(view_scroll_wheel as *const ()) }, c"v@:@", ); + + let sel = Sel::register(c"mouseDragged:"); + view_class.add_method( + sel, + unsafe { std::mem::transmute::<*const (), Imp>(view_mouse_dragged as *const ()) }, + c"v@:@", + ); } // --------------------------------------------------------------------------- @@ -759,6 +785,31 @@ impl MetalView { } } +// --------------------------------------------------------------------------- +// Keyboard modifier flags +// --------------------------------------------------------------------------- + +/// Modifier key state for keyboard and mouse events. +#[derive(Debug, Clone, Copy, Default)] +pub struct KeyModifiers { + pub shift: bool, + pub command: bool, + pub option: bool, + pub control: bool, +} + +impl KeyModifiers { + /// Extract modifier flags from an NSEvent `modifierFlags` bitmask. + pub fn from_flags(flags: u64) -> Self { + KeyModifiers { + shift: (flags & 0x20000) != 0, // NSEventModifierFlagShift + command: (flags & 0x100000) != 0, // NSEventModifierFlagCommand + option: (flags & 0x80000) != 0, // NSEventModifierFlagOption + control: (flags & 0x40000) != 0, // NSEventModifierFlagControl + } + } +} + // --------------------------------------------------------------------------- // Global resize handler // --------------------------------------------------------------------------- @@ -803,24 +854,62 @@ pub fn set_scroll_handler(handler: fn(f64, f64, f64, f64)) { } /// Global key-down callback, called from `keyDown:` with the key code, -/// character string, and whether the shift key was held. +/// character string, and modifier key state. /// /// # Safety /// /// Accessed only from the main thread (the AppKit event loop). -static mut KEY_HANDLER: Option = None; +static mut KEY_HANDLER: Option = None; /// Register a function to be called when a key-down event occurs. /// -/// The handler receives `(key_code, characters, shift_held)`. +/// The handler receives `(key_code, characters, modifiers)`. /// Only one handler can be active at a time. -pub fn set_key_handler(handler: fn(u16, &str, bool)) { +pub fn set_key_handler(handler: fn(u16, &str, KeyModifiers)) { // SAFETY: Called from the main thread before `app.run()`. unsafe { KEY_HANDLER = Some(handler); } } +/// Global mouse-down callback, called from `mouseDown:` with the click +/// location in view coordinates, click count, and modifier flags. +/// +/// # Safety +/// +/// Accessed only from the main thread (the AppKit event loop). +static mut MOUSE_DOWN_HANDLER: Option = None; + +/// Register a function to be called when a mouse-down event occurs. +/// +/// The handler receives `(x, y, click_count, modifiers)`. +/// Only one handler can be active at a time. +pub fn set_mouse_down_handler(handler: fn(f64, f64, u32, KeyModifiers)) { + // SAFETY: Called from the main thread before `app.run()`. + unsafe { + MOUSE_DOWN_HANDLER = Some(handler); + } +} + +/// Global mouse-dragged callback, called from `mouseDragged:` with the +/// current drag location in view coordinates. +/// +/// # Safety +/// +/// Accessed only from the main thread (the AppKit event loop). +static mut MOUSE_DRAGGED_HANDLER: Option = None; + +/// Register a function to be called when the mouse is dragged. +/// +/// The handler receives `(x, y)`. +/// Only one handler can be active at a time. +pub fn set_mouse_dragged_handler(handler: fn(f64, f64)) { + // SAFETY: Called from the main thread before `app.run()`. + unsafe { + MOUSE_DRAGGED_HANDLER = Some(handler); + } +} + // --------------------------------------------------------------------------- // Window delegate for handling resize and close events // --------------------------------------------------------------------------- @@ -1018,6 +1107,64 @@ pub fn is_dark_mode() -> bool { contains } +// --------------------------------------------------------------------------- +// Clipboard (NSPasteboard) +// --------------------------------------------------------------------------- + +/// Read a UTF-8 string from the general pasteboard (system clipboard). +/// +/// Returns `None` if the pasteboard does not contain a string. +pub fn clipboard_get_string() -> Option { + let cls = class!("NSPasteboard")?; + let pb: *mut c_void = msg_send![cls.as_ptr(), generalPasteboard]; + if pb.is_null() { + return None; + } + // [pb stringForType:NSPasteboardTypeString] + let ns_string_type = CfString::new("public.utf8-plain-text")?; + let string: *mut c_void = msg_send![pb, stringForType: ns_string_type.as_void_ptr()]; + if string.is_null() { + return None; + } + let utf8: *const c_char = msg_send![string, UTF8String]; + if utf8.is_null() { + return None; + } + let c_str = unsafe { CStr::from_ptr(utf8) }; + c_str.to_str().ok().map(|s| s.to_string()) +} + +/// Write a UTF-8 string to the general pasteboard (system clipboard). +/// +/// Returns `true` on success. +pub fn clipboard_set_string(text: &str) -> bool { + let cls = match class!("NSPasteboard") { + Some(c) => c, + None => return false, + }; + let pb: *mut c_void = msg_send![cls.as_ptr(), generalPasteboard]; + if pb.is_null() { + return false; + } + // [pb clearContents] + let _: *mut c_void = msg_send![pb, clearContents]; + // [pb setString:str forType:NSPasteboardTypeString] + let ns_string = match CfString::new(text) { + Some(s) => s, + None => return false, + }; + let ns_type = match CfString::new("public.utf8-plain-text") { + Some(s) => s, + None => return false, + }; + let result: bool = msg_send![ + pb, + setString: ns_string.as_void_ptr(), + forType: ns_type.as_void_ptr() + ]; + result +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1137,4 +1284,42 @@ mod tests { "WeWindowDelegate should respond to windowDidResize:" ); } + + #[test] + fn key_modifiers_from_flags() { + let mods = KeyModifiers::from_flags(0); + assert!(!mods.shift); + assert!(!mods.command); + assert!(!mods.option); + assert!(!mods.control); + + // Shift + let mods = KeyModifiers::from_flags(0x20000); + assert!(mods.shift); + assert!(!mods.command); + + // Command + let mods = KeyModifiers::from_flags(0x100000); + assert!(mods.command); + assert!(!mods.shift); + + // Option + let mods = KeyModifiers::from_flags(0x80000); + assert!(mods.option); + + // Combined + let mods = KeyModifiers::from_flags(0x20000 | 0x100000); + assert!(mods.shift); + assert!(mods.command); + } + + #[test] + fn we_view_responds_to_mouse_dragged() { + let _pool = AutoreleasePool::new(); + register_we_view_class(); + let cls = class!("WeView").expect("WeView should be registered"); + let sel = Sel::register(c"mouseDragged:"); + let responds: bool = msg_send![cls.as_ptr(), instancesRespondToSelector: sel.as_ptr()]; + assert!(responds, "WeView should respond to mouseDragged:"); + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e1835ee..a38d966 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -706,6 +706,20 @@ const FC_FOCUS_RING_COLOR: Color = Color { b: 204, a: 255, }; +/// Text selection highlight color (standard macOS blue selection). +const FC_SELECTION_COLOR: Color = Color { + r: 179, + g: 215, + b: 254, + a: 255, +}; +/// Text cursor (caret) color. +const FC_CURSOR_COLOR: Color = Color { + r: 0, + g: 0, + b: 0, + a: 255, +}; /// Paint a form control with native-style appearance. fn paint_form_control( @@ -857,16 +871,44 @@ fn paint_text_input( color: FC_BUTTON_BORDER_LIGHT, }); - // Value text (clipped to content area) - if !fc.value.is_empty() { - let display_text = if fc.control_type == FormControlType::Password { - "\u{2022}".repeat(fc.value.len()) // bullet characters + // Content area for text. + let font_size = layout_box.font_size; + let text_x = layout_box.rect.x + tx; + let text_y = layout_box.rect.y + ty; + let content_h = layout_box.rect.height; + + // For password fields, map byte offsets through the bullet-character mapping. + let display_text = if fc.control_type == FormControlType::Password { + "\u{2022}".repeat(fc.value.chars().count()) + } else { + fc.value.clone() + }; + + // Approximate character width (monospace assumption for cursor positioning). + let char_width = font_size * 0.6; + + // Paint selection highlight (behind text) when focused and selection exists. + if fc.focused && fc.cursor != fc.selection_anchor { + let (sel_start, sel_end) = if fc.cursor <= fc.selection_anchor { + (fc.cursor, fc.selection_anchor) } else { - fc.value.clone() + (fc.selection_anchor, fc.cursor) }; - let font_size = layout_box.font_size; - let text_x = layout_box.rect.x + tx; - let text_y = layout_box.rect.y + ty; + let start_chars = char_count_for_bytes(&fc.value, sel_start); + let end_chars = char_count_for_bytes(&fc.value, sel_end); + let sel_x = text_x + start_chars as f32 * char_width; + let sel_w = (end_chars - start_chars) as f32 * char_width; + list.push(PaintCommand::FillRect { + x: sel_x, + y: text_y, + width: sel_w, + height: content_h, + color: FC_SELECTION_COLOR, + }); + } + + // Value text (clipped to content area). + if !display_text.is_empty() { list.push(PaintCommand::DrawGlyphs { line: TextLine { text: display_text, @@ -882,6 +924,26 @@ fn paint_text_input( color: text_color, }); } + + // Paint cursor (caret) when focused and no selection. + if fc.focused && fc.cursor == fc.selection_anchor { + let cursor_chars = char_count_for_bytes(&fc.value, fc.cursor); + let cursor_x = text_x + cursor_chars as f32 * char_width; + let cursor_w = 1.0f32; + list.push(PaintCommand::FillRect { + x: cursor_x, + y: text_y, + width: cursor_w, + height: content_h, + color: FC_CURSOR_COLOR, + }); + } +} + +/// Count the number of Unicode characters in `s[..byte_offset]`. +fn char_count_for_bytes(s: &str, byte_offset: usize) -> usize { + let clamped = byte_offset.min(s.len()); + s[..clamped].chars().count() } /// Paint a textarea: same style as text input but taller. diff --git a/crates/style/src/matching.rs b/crates/style/src/matching.rs index addbf56..0469288 100644 --- a/crates/style/src/matching.rs +++ b/crates/style/src/matching.rs @@ -206,9 +206,7 @@ fn matches_pseudo_class(doc: &Document, node: NodeId, name: &str) -> bool { match name { "focus" => doc.active_element() == Some(node), "focus-visible" => doc.active_element() == Some(node) && doc.is_focus_visible(), - "focus-within" => { - doc.active_element() == Some(node) || doc.is_focus_within(node) - } + "focus-within" => doc.active_element() == Some(node) || doc.is_focus_within(node), "disabled" => doc.get_attribute(node, "disabled").is_some(), "enabled" => { matches!( @@ -216,9 +214,7 @@ fn matches_pseudo_class(doc: &Document, node: NodeId, name: &str) -> bool { Some("input" | "select" | "textarea" | "button") ) && doc.get_attribute(node, "disabled").is_none() } - "checked" => { - doc.get_attribute(node, "checked").is_some() - } + "checked" => doc.get_attribute(node, "checked").is_some(), _ => false, } }