diff --git a/crates/op-editor-core/src/collab_ui_debug.rs b/crates/op-editor-core/src/collab_ui_debug.rs index 83c2d052d..68dc8966d 100644 --- a/crates/op-editor-core/src/collab_ui_debug.rs +++ b/crates/op-editor-core/src/collab_ui_debug.rs @@ -10,9 +10,8 @@ impl fmt::Debug for CollabPanelState { .debug_struct("CollabPanelState") .field("open", &self.open) .field("view", &self.view) - .field("join_address", &"[REDACTED]") + .field("join_input", &"[REDACTED]") .field("join_address_focused", &self.join_address_focused) - .field("join_address_selected", &self.join_address_selected) .field("hover", &self.hover) .field("discovered", &self.discovered) .finish() @@ -58,10 +57,8 @@ mod tests { #[test] fn join_address_is_redacted_from_debug() { - let panel = CollabPanelState { - join_address: "opc1_secret-route-capability".to_owned(), - ..CollabPanelState::default() - }; + let mut panel = CollabPanelState::default(); + panel.join_input.set_text("opc1_secret-route-capability"); let debug = format!("{panel:?}"); assert!(debug.contains("[REDACTED]")); assert!(!debug.contains("secret-route-capability")); diff --git a/crates/op-editor-core/src/collab_ui_state.rs b/crates/op-editor-core/src/collab_ui_state.rs index 991d2eca7..9684a252e 100644 --- a/crates/op-editor-core/src/collab_ui_state.rs +++ b/crates/op-editor-core/src/collab_ui_state.rs @@ -106,15 +106,14 @@ pub struct DiscoveredCollabEndpoint { pub compatible: bool, } -#[derive(Clone, PartialEq, Eq, Default)] +#[derive(Clone, PartialEq, Default)] pub struct CollabPanelState { pub open: bool, pub view: CollabPanelView, - pub join_address: String, + /// The invite-or-`host:port` field, on the unified single-line input + /// component (caret, blink, selection, click-to-position all shared). + pub join_input: jian_core::text_input::TextInputState, pub join_address_focused: bool, - /// Whole-field selection for the plain-string join field (Cmd/Ctrl+A). - /// The next typed character or Backspace replaces/clears the field. - pub join_address_selected: bool, pub hover: Option, pub discovered: Arc>, } diff --git a/crates/op-editor-core/src/host_keyboard_transitions.rs b/crates/op-editor-core/src/host_keyboard_transitions.rs index 0b1cbfa53..0fda5e1a3 100644 --- a/crates/op-editor-core/src/host_keyboard_transitions.rs +++ b/crates/op-editor-core/src/host_keyboard_transitions.rs @@ -91,11 +91,13 @@ impl EditorUiState { && self.collab.panel.join_address_focused } - /// Drop even a stale Join-field focus bit when another surface takes over. - /// The whole-field selection dies with the focus so a later refocus never - /// resurrects a destructive replace-on-type state. + /// Drop even a stale Join-field focus bit when another surface takes + /// over. The input's selection collapses with it so a later refocus + /// never resurrects a destructive replace-on-type state. pub fn blur_collab_join_input(&mut self) -> bool { - self.collab.panel.join_address_selected = false; + let input = &mut self.collab.panel.join_input; + let end = input.text().len(); + input.set_caret(end, 0); std::mem::take(&mut self.collab.panel.join_address_focused) } diff --git a/crates/op-editor-core/src/text_input_focus.rs b/crates/op-editor-core/src/text_input_focus.rs index 5a94e10a6..3047e9a53 100644 --- a/crates/op-editor-core/src/text_input_focus.rs +++ b/crates/op-editor-core/src/text_input_focus.rs @@ -60,6 +60,11 @@ impl EditorState { if self.editor_ui.chat_model_picker.open { return Some(&self.editor_ui.chat_model_picker_input); } + // The collaboration Join field rides the visibility-aware focus + // predicate so a stale bit never claims blink/clipboard ownership. + if self.editor_ui.collab_join_input_active() { + return Some(&self.editor_ui.collab.panel.join_input); + } if self.chat.focused { return Some(&self.chat.input); } @@ -128,6 +133,9 @@ impl EditorState { if self.editor_ui.chat_model_picker.open { return Some(&mut self.editor_ui.chat_model_picker_input); } + if self.editor_ui.collab_join_input_active() { + return Some(&mut self.editor_ui.collab.panel.join_input); + } if self.chat.focused { return Some(&mut self.chat.input); } diff --git a/crates/op-editor-ui/src/widgets/collab_panel.rs b/crates/op-editor-ui/src/widgets/collab_panel.rs index 9382b5239..c879a35df 100644 --- a/crates/op-editor-ui/src/widgets/collab_panel.rs +++ b/crates/op-editor-ui/src/widgets/collab_panel.rs @@ -80,10 +80,18 @@ pub struct CollabPanel<'a> { ui: &'a EditorUiState, model: CollabPanelModel, theme: Theme, + /// Frame clock for the join field's caret blink. Hit-test-only callers + /// construct with 0 — geometry never depends on it. + now_ms: u64, } impl<'a> CollabPanel<'a> { pub fn for_editor_ui(ui: &'a EditorUiState) -> Option { + Self::for_editor_ui_at(ui, 0) + } + + /// Build with a frame clock so the join field's caret blinks. + pub fn for_editor_ui_at(ui: &'a EditorUiState, now_ms: u64) -> Option { if !ui.collab.panel.open { return None; } @@ -92,6 +100,7 @@ impl<'a> CollabPanel<'a> { ui, model: CollabPanelModel::for_editor_ui(ui), theme: theme_for(ui), + now_ms, }) } } @@ -219,7 +228,7 @@ impl Widget for CollabPanel<'_> { self.paint_owner_confirmation(cx, rect, body_top, confirm); } CollabPanelScreen::Join { - address, + address: _, discovered, } => { paint_text( @@ -253,42 +262,30 @@ impl Widget for CollabPanel<'_> { 1.0, ); let clear = self.clear_join_rect(rect, body_top + 22.0); - let text_width = if clear.is_some() { - input.size.x - 18.0 - CLEAR_BUTTON_SIZE + let text_inset = if clear.is_some() { + CLEAR_BUTTON_SIZE + 5.0 } else { - input.size.x - 18.0 + 0.0 }; - let shown = if address.is_empty() { - op_i18n::translate(self.ui.locale, "collab.join.codePlaceholder").to_string() - } else { - crate::util::ellipsize_to_width(address, text_width, |text| { - cx.backend.measure_text(text, 12.0) - }) - }; - if !address.is_empty() - && self.ui.collab.panel.join_address_focused - && self.ui.collab.panel.join_address_selected - { - let selection = Rect::xywh( - input.origin.x + 6.0, - input.origin.y + 6.0, - cx.backend.measure_text(&shown, 12.0).min(text_width) + 6.0, - input.size.y - 12.0, - ); - cx.backend - .fill_round_rect(selection, 4.0, self.theme.ring.with_alpha(0.35)); - } - paint_text( + // Value, placeholder, selection highlight, and blinking caret + // all render through the unified text-input view. + let view_rect = Rect::xywh( + input.origin.x, + input.origin.y, + input.size.x - text_inset, + input.size.y, + ); + crate::widgets::property_panel_text_input::paint_text_input_view( cx, - &shown, + &self.theme, + &self.ui.collab.panel.join_input, + view_rect, 12.0, - if address.is_empty() { - self.theme.muted_foreground - } else { - self.theme.foreground - }, - Point2D::new(input.origin.x + 9.0, input.origin.y + 21.0), - 400, + 9.0, + input.origin.y + 21.0, + self.now_ms, + op_i18n::translate(self.ui.locale, "collab.join.codePlaceholder"), + self.ui.collab.panel.join_address_focused, ); if let Some(clear) = clear { if self.ui.collab.panel.hover == Some(CollabPanelHover::ClearJoinAddress) { diff --git a/crates/op-editor-ui/src/widgets/collab_panel_interaction.rs b/crates/op-editor-ui/src/widgets/collab_panel_interaction.rs index 666517062..d1a198f1c 100644 --- a/crates/op-editor-ui/src/widgets/collab_panel_interaction.rs +++ b/crates/op-editor-ui/src/widgets/collab_panel_interaction.rs @@ -310,7 +310,7 @@ impl CollabPanel<'_> { /// Clear (×) affordance inside the join field. `None` while the field is /// empty so an idle input never paints or hit-tests a dead button. pub(super) fn clear_join_rect(&self, panel: Rect, y: f32) -> Option { - if self.ui.collab.panel.join_address.is_empty() { + if self.ui.collab.panel.join_input.text().is_empty() { return None; } let input = self.address_rect(panel, y); diff --git a/crates/op-editor-ui/src/widgets/collab_panel_tests.rs b/crates/op-editor-ui/src/widgets/collab_panel_tests.rs index ebdfe647b..8014d775b 100644 --- a/crates/op-editor-ui/src/widgets/collab_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/collab_panel_tests.rs @@ -162,7 +162,7 @@ fn join_controls_hover_only_when_they_are_interactive() { ui.collab.availability = CollabAvailability::Ready; ui.collab.panel.open = true; ui.collab.panel.view = CollabPanelView::Join; - ui.collab.panel.join_address = "opc1_public-invite".into(); + ui.collab.panel.join_input.set_text("opc1_public-invite"); ui.collab.panel.discovered = Arc::new(vec![ DiscoveredCollabEndpoint { discovery_id: "compatible".into(), @@ -527,7 +527,7 @@ fn height_clamped_body_paint_is_clipped_above_action_row() { ui.collab.availability = CollabAvailability::Ready; ui.collab.panel.open = true; ui.collab.panel.view = CollabPanelView::Join; - ui.collab.panel.join_address = "198.51.100.42:443".into(); + ui.collab.panel.join_input.set_text("198.51.100.42:443"); let panel = CollabPanel::for_editor_ui(&ui).unwrap(); let rect = panel.rect_at( Rect::xywh(600.0, 8.0, 100.0, 26.0), @@ -720,7 +720,7 @@ fn join_clear_button_wins_inside_the_input_and_needs_content() { ui.collab.availability = CollabAvailability::Ready; ui.collab.panel.open = true; ui.collab.panel.view = CollabPanelView::Join; - ui.collab.panel.join_address = "opc1_public-invite".into(); + ui.collab.panel.join_input.set_text("opc1_public-invite"); let panel = CollabPanel::for_editor_ui(&ui).unwrap(); let rect = panel.rect_at(Rect::xywh(600.0, 8.0, 100.0, 26.0), viewport()); diff --git a/crates/op-editor-ui/src/widgets/collab_ui.rs b/crates/op-editor-ui/src/widgets/collab_ui.rs index a17c74c71..cc7b87062 100644 --- a/crates/op-editor-ui/src/widgets/collab_ui.rs +++ b/crates/op-editor-ui/src/widgets/collab_ui.rs @@ -346,7 +346,7 @@ fn panel_session_or_pre_auth( || collab.phase == CollabConnectionPhase::Discovering { let mut actions = Vec::new(); - let endpoint = collab.panel.join_address.trim(); + let endpoint = collab.panel.join_input.text().trim(); if !endpoint.is_empty() { actions.push(CollabPanelActionModel { action: CollabUiAction::JoinAddress { @@ -366,7 +366,7 @@ fn panel_session_or_pre_auth( actions.push(action_model(ui, CollabUiAction::Cancel, false)); ( CollabPanelScreen::Join { - address: collab.panel.join_address.clone(), + address: collab.panel.join_input.text().to_owned(), discovered: collab.panel.discovered.as_ref().clone(), }, actions, @@ -464,20 +464,20 @@ pub fn apply_panel_hit( CollabPanelHit::Close => { ui.collab.panel.open = false; ui.collab.panel.join_address_focused = false; - ui.collab.panel.join_address_selected = false; ui.collab.panel.hover = None; true } CollabPanelHit::FocusJoinAddress => { - // A plain click focuses with a collapsed caret; it never keeps a - // stale whole-field selection alive. + // A plain click focuses with a collapsed caret at the end; it + // never keeps a stale whole-field selection alive. ui.collab.panel.join_address_focused = true; - ui.collab.panel.join_address_selected = false; + let input = &mut ui.collab.panel.join_input; + let end = input.text().len(); + input.set_caret(end, 0); true } CollabPanelHit::ClearJoinAddress => { - ui.collab.panel.join_address.clear(); - ui.collab.panel.join_address_selected = false; + ui.collab.panel.join_input.set_text(""); ui.collab.panel.join_address_focused = true; ui.collab.panel.hover = None; true @@ -490,7 +490,6 @@ pub fn apply_panel_hit( ui.login_modal_hover = None; ui.collab.panel.open = false; ui.collab.panel.join_address_focused = false; - ui.collab.panel.join_address_selected = false; ui.collab.panel.hover = None; true } @@ -500,28 +499,22 @@ pub fn apply_panel_hit( CollabPanelHit::CopyInvite(_) => false, CollabPanelHit::Inside => { ui.collab.panel.join_address_focused = false; - ui.collab.panel.join_address_selected = false; true } CollabPanelHit::Action(CollabUiAction::OpenCreate) => { ui.collab.panel.view = op_editor_core::CollabPanelView::Create; ui.collab.panel.join_address_focused = false; - ui.collab.panel.join_address_selected = false; ui.collab.panel.hover = None; true } CollabPanelHit::Action(CollabUiAction::OpenJoin) => { ui.collab.panel.view = op_editor_core::CollabPanelView::Join; ui.collab.panel.join_address_focused = true; - ui.collab.panel.join_address_selected = false; ui.collab.panel.hover = None; true } CollabPanelHit::Action(CollabUiAction::BeginDiscovery) => { ui.collab.panel.view = op_editor_core::CollabPanelView::Join; - // Find-nearby keeps the field focused; a surviving whole-field - // selection would make the next keystroke destructive. - ui.collab.panel.join_address_selected = false; request_action(ui, CollabUiAction::BeginDiscovery) } CollabPanelHit::Action(CollabUiAction::Cancel) @@ -529,13 +522,11 @@ pub fn apply_panel_hit( { ui.collab.panel.view = op_editor_core::CollabPanelView::Home; ui.collab.panel.join_address_focused = false; - ui.collab.panel.join_address_selected = false; ui.collab.panel.hover = None; true } CollabPanelHit::Action(action) => { ui.collab.panel.join_address_focused = false; - ui.collab.panel.join_address_selected = false; request_action(ui, action) } } @@ -543,27 +534,25 @@ pub fn apply_panel_hit( /// Typed-character routing for the invite-or-`host:port` field. `None` means /// the collaboration input is not focused; `Some` means it owns the key. -pub fn join_address_text(ui: &mut EditorUiState, character: char) -> Option { +pub fn join_address_text(ui: &mut EditorUiState, character: char, now_ms: u64) -> Option { if !ui.collab.panel.join_address_focused { return None; } - if character.is_control() { + if character.is_control() || !join_address_char_allowed(character) { return Some(false); } - if !join_address_char_allowed(character) { + let input = &mut ui.collab.panel.join_input; + // The cap applies to the post-edit length: typing over a selection must + // still be able to replace a full field. + let selected = input + .highlight_range() + .map(|(start, end)| end - start) + .unwrap_or(0); + if input.text().chars().count() - selected >= MAX_JOIN_TARGET_CHARS { return Some(false); } - // A whole-field selection replaces on type, like every range-selection - // input: the first accepted character clears the old value. The length - // cap below deliberately runs AFTER the take — a full field must still - // be replaceable by typing over the selection. - if std::mem::take(&mut ui.collab.panel.join_address_selected) { - ui.collab.panel.join_address.clear(); - } - if ui.collab.panel.join_address.chars().count() >= MAX_JOIN_TARGET_CHARS { - return Some(false); - } - ui.collab.panel.join_address.push(character); + let mut buffer = [0_u8; 4]; + input.insert_str(character.encode_utf8(&mut buffer), now_ms); ui.collab.panel.hover = None; Some(true) } @@ -575,19 +564,29 @@ fn join_address_char_allowed(character: char) -> bool { character.is_ascii_alphanumeric() || matches!(character, '.' | ':' | '-' | '[' | ']' | '_') } -pub fn join_address_backspace(ui: &mut EditorUiState) -> Option { +pub fn join_address_backspace(ui: &mut EditorUiState, now_ms: u64) -> Option { if !ui.collab.panel.join_address_focused { return None; } - if std::mem::take(&mut ui.collab.panel.join_address_selected) { - let changed = !ui.collab.panel.join_address.is_empty(); - ui.collab.panel.join_address.clear(); - if changed { - ui.collab.panel.hover = None; - } - return Some(changed); + let input = &mut ui.collab.panel.join_input; + let before = input.text().to_owned(); + input.backspace(now_ms); + let changed = input.text() != before; + if changed { + ui.collab.panel.hover = None; } - let changed = ui.collab.panel.join_address.pop().is_some(); + Some(changed) +} + +/// Forward deletion (the Delete key) on the focused join field. +pub fn join_address_delete_forward(ui: &mut EditorUiState, now_ms: u64) -> Option { + if !ui.collab.panel.join_address_focused { + return None; + } + let input = &mut ui.collab.panel.join_input; + let before = input.text().to_owned(); + input.delete_forward(now_ms); + let changed = input.text() != before; if changed { ui.collab.panel.hover = None; } @@ -596,19 +595,23 @@ pub fn join_address_backspace(ui: &mut EditorUiState) -> Option { /// Cmd/Ctrl+A on the focused join field — whole-field selection. `None` /// means the field is not focused and the chord belongs to someone else. -pub fn join_address_select_all(ui: &mut EditorUiState) -> Option { +pub fn join_address_select_all(ui: &mut EditorUiState, now_ms: u64) -> Option { if !ui.collab.panel.join_address_focused { return None; } - let selectable = !ui.collab.panel.join_address.is_empty(); - ui.collab.panel.join_address_selected = selectable; + let input = &mut ui.collab.panel.join_input; + let selectable = !input.text().is_empty(); + if selectable { + input.select_all(); + input.touch(now_ms); + } Some(selectable) } /// Clipboard paste into the focused join field. Replaces the whole field — /// an invite code is pasted as a unit, and append semantics silently /// produced corrupt old+new concatenations. `None` means not focused. -pub fn join_address_paste(ui: &mut EditorUiState, text: &str) -> Option { +pub fn join_address_paste(ui: &mut EditorUiState, text: &str, now_ms: u64) -> Option { if !ui.collab.panel.join_address_focused { return None; } @@ -620,8 +623,9 @@ pub fn join_address_paste(ui: &mut EditorUiState, text: &str) -> Option { if sanitized.is_empty() { return Some(false); } - ui.collab.panel.join_address = sanitized; - ui.collab.panel.join_address_selected = false; + let input = &mut ui.collab.panel.join_input; + input.set_text(sanitized); + input.touch(now_ms); ui.collab.panel.hover = None; Some(true) } @@ -630,7 +634,7 @@ pub fn join_address_submit(ui: &mut EditorUiState) -> Option { if !ui.collab.panel.join_address_focused { return None; } - let endpoint = ui.collab.panel.join_address.trim(); + let endpoint = ui.collab.panel.join_input.text().trim(); if endpoint.is_empty() { return Some(false); } @@ -638,7 +642,6 @@ pub fn join_address_submit(ui: &mut EditorUiState) -> Option { endpoint: endpoint.to_string(), }; ui.collab.panel.join_address_focused = false; - ui.collab.panel.join_address_selected = false; ui.collab.panel.hover = None; Some(request_action(ui, action)) } diff --git a/crates/op-editor-ui/src/widgets/collab_ui_model_tests.rs b/crates/op-editor-ui/src/widgets/collab_ui_model_tests.rs index 7db3cb33d..d9d4f5d07 100644 --- a/crates/op-editor-ui/src/widgets/collab_ui_model_tests.rs +++ b/crates/op-editor-ui/src/widgets/collab_ui_model_tests.rs @@ -82,7 +82,7 @@ fn join_model_exposes_only_discovery_endpoint_data() { let mut ui = EditorUiState::default(); ui.collab.availability = CollabAvailability::Ready; ui.collab.panel.view = CollabPanelView::Join; - ui.collab.panel.join_address = "10.0.0.2:43120".to_string(); + ui.collab.panel.join_input.set_text("10.0.0.2:43120"); ui.collab.panel.discovered = std::sync::Arc::new(vec![DiscoveredCollabEndpoint { discovery_id: "opaque-1".to_string(), endpoint: "10.0.0.3:43120".to_string(), diff --git a/crates/op-editor-ui/src/widgets/collab_ui_tests.rs b/crates/op-editor-ui/src/widgets/collab_ui_tests.rs index bd68bc51c..2b8a347af 100644 --- a/crates/op-editor-ui/src/widgets/collab_ui_tests.rs +++ b/crates/op-editor-ui/src/widgets/collab_ui_tests.rs @@ -108,9 +108,9 @@ fn invite_or_address_input_is_bounded_and_queues_one_join() { let mut ui = EditorUiState::default(); ui.collab.panel.join_address_focused = true; for character in target.chars() { - assert_eq!(join_address_text(&mut ui, character), Some(true)); + assert_eq!(join_address_text(&mut ui, character, 0), Some(true)); } - assert_eq!(join_address_text(&mut ui, ' '), Some(false)); + assert_eq!(join_address_text(&mut ui, ' ', 0), Some(false)); assert_eq!(join_address_submit(&mut ui), Some(true)); assert_eq!( ui.collab.take_pending_action(), @@ -127,7 +127,7 @@ fn join_target_and_action_debug_are_redacted() { let mut ui = EditorUiState::default(); ui.collab.availability = CollabAvailability::Ready; ui.collab.panel.view = CollabPanelView::Join; - ui.collab.panel.join_address = raw_invite.into(); + ui.collab.panel.join_input.set_text(raw_invite); let model = CollabPanelModel::for_editor_ui(&ui); let debug = format!("{model:?}"); @@ -273,40 +273,42 @@ fn conflict_notice_names_the_discarded_fields_and_offers_reapply() { fn paste_replaces_the_whole_join_field() { let mut ui = EditorUiState::default(); ui.collab.panel.join_address_focused = true; - ui.collab.panel.join_address = "opc1_stale-old-code".into(); + ui.collab.panel.join_input.set_text("opc1_stale-old-code"); - assert_eq!(join_address_paste(&mut ui, "opc1_fresh_code\n"), Some(true)); - assert_eq!(ui.collab.panel.join_address, "opc1_fresh_code"); + assert_eq!( + join_address_paste(&mut ui, "opc1_fresh_code\n", 0), + Some(true) + ); + assert_eq!(ui.collab.panel.join_input.text(), "opc1_fresh_code"); // Whitespace-only payloads change nothing rather than clearing the field. - assert_eq!(join_address_paste(&mut ui, " \n\t"), Some(false)); - assert_eq!(ui.collab.panel.join_address, "opc1_fresh_code"); + assert_eq!(join_address_paste(&mut ui, " \n\t", 0), Some(false)); + assert_eq!(ui.collab.panel.join_input.text(), "opc1_fresh_code"); ui.collab.panel.join_address_focused = false; - assert_eq!(join_address_paste(&mut ui, "opc1_x"), None); + assert_eq!(join_address_paste(&mut ui, "opc1_x", 0), None); } #[test] fn select_all_then_backspace_clears_and_type_replaces() { let mut ui = EditorUiState::default(); ui.collab.panel.join_address_focused = true; - ui.collab.panel.join_address = "opc1_very-long-invite".into(); + ui.collab.panel.join_input.set_text("opc1_very-long-invite"); - assert_eq!(join_address_select_all(&mut ui), Some(true)); - assert!(ui.collab.panel.join_address_selected); - assert_eq!(join_address_backspace(&mut ui), Some(true)); - assert!(ui.collab.panel.join_address.is_empty()); - assert!(!ui.collab.panel.join_address_selected); + assert_eq!(join_address_select_all(&mut ui, 0), Some(true)); + assert!(ui.collab.panel.join_input.highlight_range().is_some()); + assert_eq!(join_address_backspace(&mut ui, 0), Some(true)); + assert!(ui.collab.panel.join_input.text().is_empty()); // Select-all on an empty field selects nothing. - assert_eq!(join_address_select_all(&mut ui), Some(false)); - assert!(!ui.collab.panel.join_address_selected); + assert_eq!(join_address_select_all(&mut ui, 0), Some(false)); + assert!(ui.collab.panel.join_input.highlight_range().is_none()); - ui.collab.panel.join_address = "opc1_old".into(); - assert_eq!(join_address_select_all(&mut ui), Some(true)); - assert_eq!(join_address_text(&mut ui, 'x'), Some(true)); - assert_eq!(ui.collab.panel.join_address, "x"); - assert!(!ui.collab.panel.join_address_selected); + ui.collab.panel.join_input.set_text("opc1_old"); + assert_eq!(join_address_select_all(&mut ui, 0), Some(true)); + assert_eq!(join_address_text(&mut ui, 'x', 0), Some(true)); + assert_eq!(ui.collab.panel.join_input.text(), "x"); + assert!(ui.collab.panel.join_input.highlight_range().is_none()); } #[test] @@ -315,38 +317,41 @@ fn clear_hit_empties_the_field_and_keeps_focus() { ui.collab.availability = CollabAvailability::Ready; ui.collab.panel.open = true; ui.collab.panel.view = CollabPanelView::Join; - ui.collab.panel.join_address = "opc1_something".into(); - ui.collab.panel.join_address_selected = true; + ui.collab.panel.join_input.set_text("opc1_something"); + ui.collab.panel.join_input.select_all(); assert!(apply_panel_hit( &mut ui, crate::widgets::collab_panel::CollabPanelHit::ClearJoinAddress, )); - assert!(ui.collab.panel.join_address.is_empty()); + assert!(ui.collab.panel.join_input.text().is_empty()); assert!(ui.collab.panel.join_address_focused); - assert!(!ui.collab.panel.join_address_selected); + assert!(ui.collab.panel.join_input.highlight_range().is_none()); } #[test] -fn blur_paths_drop_the_whole_field_selection() { +fn refocus_by_click_collapses_a_stale_selection() { let mut ui = EditorUiState::default(); ui.collab.panel.join_address_focused = true; - ui.collab.panel.join_address = "opc1_abc".into(); - ui.collab.panel.join_address_selected = true; + ui.collab.panel.join_input.set_text("opc1_abc"); + ui.collab.panel.join_input.select_all(); assert!(apply_panel_hit( &mut ui, crate::widgets::collab_panel::CollabPanelHit::Inside, )); assert!(!ui.collab.panel.join_address_focused); - assert!(!ui.collab.panel.join_address_selected); - // Re-focusing by click never resurrects a stale selection. - ui.collab.panel.join_address_selected = true; + // Re-focusing by click never resurrects a stale selection: the caret + // collapses to the end of the buffer. assert!(apply_panel_hit( &mut ui, crate::widgets::collab_panel::CollabPanelHit::FocusJoinAddress, )); assert!(ui.collab.panel.join_address_focused); - assert!(!ui.collab.panel.join_address_selected); + assert!(ui.collab.panel.join_input.highlight_range().is_none()); + assert_eq!( + ui.collab.panel.join_input.caret(), + ui.collab.panel.join_input.text().len() + ); } diff --git a/crates/op-host-native/src/widget_host/collab_input_tests.rs b/crates/op-host-native/src/widget_host/collab_input_tests.rs index 3e6a411a6..bcdd09d1d 100644 --- a/crates/op-host-native/src/widget_host/collab_input_tests.rs +++ b/crates/op-host-native/src/widget_host/collab_input_tests.rs @@ -21,7 +21,7 @@ fn focus_join(host: &mut WidgetHostNative) { collab.panel.open = true; collab.panel.view = CollabPanelView::Join; collab.panel.join_address_focused = true; - collab.panel.join_address.clear(); + collab.panel.join_input.set_text(""); } fn host_with_selected_node() -> WidgetHostNative { @@ -52,17 +52,17 @@ fn join_field_owns_native_text_ime_paste_backspace_and_enter() { assert!(host.apply_input_paste("opc1_Ab-9\n")); assert!(host.apply_ime_commit("Z")); assert_eq!( - host.editor_state().editor_ui.collab.panel.join_address, + host.editor_state().editor_ui.collab.panel.join_input.text(), "opc1_Ab-9Z" ); assert!(host.apply_text('/'), "rejected input is still consumed"); assert_eq!( - host.editor_state().editor_ui.collab.panel.join_address, + host.editor_state().editor_ui.collab.panel.join_input.text(), "opc1_Ab-9Z" ); assert!(host.apply_backspace()); assert_eq!( - host.editor_state().editor_ui.collab.panel.join_address, + host.editor_state().editor_ui.collab.panel.join_input.text(), "opc1_Ab-9" ); @@ -269,20 +269,21 @@ fn native_join_paste_replaces_and_select_all_clears() { assert!(host.apply_input_paste("opc1_first-code")); assert!(host.apply_input_paste("opc1_second-code")); assert_eq!( - host.editor_state().editor_ui.collab.panel.join_address, + host.editor_state().editor_ui.collab.panel.join_input.text(), "opc1_second-code", "a pasted invite replaces the stale one instead of appending" ); // Cmd/Ctrl+A owns the chord and selects the whole field... assert!(host.apply_select_all()); - assert!( - host.editor_state() - .editor_ui - .collab - .panel - .join_address_selected - ); + assert!(host + .editor_state() + .editor_ui + .collab + .panel + .join_input + .highlight_range() + .is_some()); // ...so one Backspace clears it. assert!(host.apply_backspace()); assert!(host @@ -290,7 +291,8 @@ fn native_join_paste_replaces_and_select_all_clears() { .editor_ui .collab .panel - .join_address + .join_input + .text() .is_empty()); } @@ -298,12 +300,11 @@ fn native_join_paste_replaces_and_select_all_clears() { fn native_join_delete_clears_selection_and_never_deletes_nodes() { let mut host = host_with_selected_node(); let before = host.editor_state().active_children().len(); - host.editor_state_mut().editor_ui.collab.panel.join_address = "opc1_code".into(); - host.editor_state_mut() - .editor_ui - .collab - .panel - .join_address_selected = true; + { + let input = &mut host.editor_state_mut().editor_ui.collab.panel.join_input; + input.set_text("opc1_code"); + input.select_all(); + } assert!(host.apply_delete()); assert!(host @@ -311,7 +312,8 @@ fn native_join_delete_clears_selection_and_never_deletes_nodes() { .editor_ui .collab .panel - .join_address + .join_input + .text() .is_empty()); // Delete with an empty, unselected field is still swallowed. assert!(!host.apply_delete()); diff --git a/crates/op-host-native/src/widget_host/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index 66b0965fc..09ad918af 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -33,6 +33,7 @@ impl WidgetHostNative { let changed = op_editor_ui::widgets::collab_ui::join_address_text( &mut self.editor_state.editor_ui, c, + self.now_ms, ) .unwrap_or(false); if changed { diff --git a/crates/op-host-native/src/widget_host/keyboard_clipboard.rs b/crates/op-host-native/src/widget_host/keyboard_clipboard.rs index 85314e33c..967bd3ae2 100644 --- a/crates/op-host-native/src/widget_host/keyboard_clipboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard_clipboard.rs @@ -38,6 +38,7 @@ impl WidgetHostNative { let changed = op_editor_ui::widgets::collab_ui::join_address_paste( &mut self.editor_state.editor_ui, text, + self.now_ms, ) .unwrap_or(false); if changed { diff --git a/crates/op-host-native/src/widget_host/keyboard_delete.rs b/crates/op-host-native/src/widget_host/keyboard_delete.rs index 2ed62fa09..5b46cf9ed 100644 --- a/crates/op-host-native/src/widget_host/keyboard_delete.rs +++ b/crates/op-host-native/src/widget_host/keyboard_delete.rs @@ -26,6 +26,7 @@ impl WidgetHostNative { if self.editor_state.editor_ui.collab_join_input_active() { let changed = op_editor_ui::widgets::collab_ui::join_address_backspace( &mut self.editor_state.editor_ui, + self.now_ms, ) .unwrap_or(false); if changed { @@ -267,25 +268,19 @@ impl WidgetHostNative { if self.apply_image_panel_delete() { return true; } - // A whole-field selection in the join input makes Delete a clear. - // Without one, `delete_owned_by_chrome_input` below still swallows - // the key before it can reach the canvas selection. - if self.editor_state.editor_ui.collab_join_input_active() - && self - .editor_state - .editor_ui - .collab - .panel - .join_address_selected - { - let changed = op_editor_ui::widgets::collab_ui::join_address_backspace( + // Forward deletion in the join input. A no-op falls through to + // `delete_owned_by_chrome_input`, which still swallows the key + // before it can reach the canvas selection. + if self.editor_state.editor_ui.collab_join_input_active() { + let changed = op_editor_ui::widgets::collab_ui::join_address_delete_forward( &mut self.editor_state.editor_ui, + self.now_ms, ) .unwrap_or(false); if changed { self.mark_dirty(); + return true; } - return true; } // The open font picker owns Delete. Its search draft handles // Backspace separately; forward-delete must never reach the canvas diff --git a/crates/op-host-native/src/widget_host/paint.rs b/crates/op-host-native/src/widget_host/paint.rs index 4f4bea5c8..b9bf05826 100644 --- a/crates/op-host-native/src/widget_host/paint.rs +++ b/crates/op-host-native/src/widget_host/paint.rs @@ -503,9 +503,10 @@ impl WidgetHostNative { // Collaboration popover — a real shared widget anchored to the // collaboration status chip. It consumes only sanitized UI state; // the native session actor drains queued actions separately. - if let Some(panel) = - op_editor_ui::widgets::CollabPanel::for_editor_ui(&self.editor_state.editor_ui) - { + if let Some(panel) = op_editor_ui::widgets::CollabPanel::for_editor_ui_at( + &self.editor_state.editor_ui, + self.now_ms, + ) { let anchor = top_bar.collaboration_chip_rect_estimated(top_bar_rect); let panel_rect = panel.rect_at( anchor, diff --git a/crates/op-host-native/src/widget_host/shortcuts.rs b/crates/op-host-native/src/widget_host/shortcuts.rs index 6bfca0627..c4287e92f 100644 --- a/crates/op-host-native/src/widget_host/shortcuts.rs +++ b/crates/op-host-native/src/widget_host/shortcuts.rs @@ -183,6 +183,7 @@ impl WidgetHostNative { if self.editor_state.editor_ui.collab_join_input_active() { if op_editor_ui::widgets::collab_ui::join_address_select_all( &mut self.editor_state.editor_ui, + self.now_ms, ) == Some(true) { self.mark_dirty(); diff --git a/crates/op-host-web/src/widget_host/collab_input_tests.rs b/crates/op-host-web/src/widget_host/collab_input_tests.rs index 1989a732e..afced3b7e 100644 --- a/crates/op-host-web/src/widget_host/collab_input_tests.rs +++ b/crates/op-host-web/src/widget_host/collab_input_tests.rs @@ -21,7 +21,7 @@ fn focus_join(host: &mut WidgetHost) { collab.panel.open = true; collab.panel.view = CollabPanelView::Join; collab.panel.join_address_focused = true; - collab.panel.join_address.clear(); + collab.panel.join_input.set_text(""); } fn host_with_selected_node() -> WidgetHost { @@ -51,17 +51,17 @@ fn join_field_owns_web_text_ime_paste_backspace_and_enter() { let ime = crate::event::ime::composition_end("Z".to_string()); assert!(host.apply_ime(&ime)); assert_eq!( - host.editor_state.editor_ui.collab.panel.join_address, + host.editor_state.editor_ui.collab.panel.join_input.text(), "opc1_Ab-9Z" ); assert!(host.apply_text('/'), "rejected input is still consumed"); assert_eq!( - host.editor_state.editor_ui.collab.panel.join_address, + host.editor_state.editor_ui.collab.panel.join_input.text(), "opc1_Ab-9Z" ); assert!(host.apply_backspace()); assert_eq!( - host.editor_state.editor_ui.collab.panel.join_address, + host.editor_state.editor_ui.collab.panel.join_input.text(), "opc1_Ab-9" ); @@ -258,7 +258,8 @@ fn web_join_clipboard_replaces_and_select_all_clears() { assert!(host.apply_clipboard_text("opc1_first-code")); assert!(host.apply_clipboard_text("opc1_second-code")); assert_eq!( - host.editor_state.editor_ui.collab.panel.join_address, "opc1_second-code", + host.editor_state.editor_ui.collab.panel.join_input.text(), + "opc1_second-code", "a pasted invite replaces the stale one instead of appending" ); @@ -266,24 +267,26 @@ fn web_join_clipboard_replaces_and_select_all_clears() { let ime = crate::event::ime::composition_end("Z".to_string()); assert!(host.apply_ime(&ime)); assert_eq!( - host.editor_state.editor_ui.collab.panel.join_address, + host.editor_state.editor_ui.collab.panel.join_input.text(), "opc1_second-codeZ" ); assert!(host.apply_select_all()); - assert!( - host.editor_state - .editor_ui - .collab - .panel - .join_address_selected - ); + assert!(host + .editor_state + .editor_ui + .collab + .panel + .join_input + .highlight_range() + .is_some()); assert!(host.apply_backspace()); assert!(host .editor_state .editor_ui .collab .panel - .join_address + .join_input + .text() .is_empty()); } diff --git a/crates/op-host-web/src/widget_host/keyboard.rs b/crates/op-host-web/src/widget_host/keyboard.rs index ed4426e16..373bee5fb 100644 --- a/crates/op-host-web/src/widget_host/keyboard.rs +++ b/crates/op-host-web/src/widget_host/keyboard.rs @@ -25,6 +25,7 @@ impl WidgetHost { let changed = op_editor_ui::widgets::collab_ui::join_address_text( &mut self.editor_state.editor_ui, c, + self.now_ms, ) .unwrap_or(false); if changed { @@ -130,6 +131,7 @@ impl WidgetHost { if self.editor_state.editor_ui.collab_join_input_active() { let changed = op_editor_ui::widgets::collab_ui::join_address_backspace( &mut self.editor_state.editor_ui, + self.now_ms, ) .unwrap_or(false); if changed { @@ -338,25 +340,19 @@ impl WidgetHost { if self.apply_image_panel_delete() { return true; } - // A whole-field selection in the join input makes Delete a clear. - // Without one, `delete_owned_by_chrome_input` below still swallows - // the key before it can reach the canvas selection. - if self.editor_state.editor_ui.collab_join_input_active() - && self - .editor_state - .editor_ui - .collab - .panel - .join_address_selected - { - let changed = op_editor_ui::widgets::collab_ui::join_address_backspace( + // Forward deletion in the join input. A no-op falls through to + // `delete_owned_by_chrome_input`, which still swallows the key + // before it can reach the canvas selection. + if self.editor_state.editor_ui.collab_join_input_active() { + let changed = op_editor_ui::widgets::collab_ui::join_address_delete_forward( &mut self.editor_state.editor_ui, + self.now_ms, ) .unwrap_or(false); if changed { self.mark_dirty(); + return true; } - return true; } // The open font picker owns Delete. Its search draft handles // Backspace separately; forward-delete must never reach the canvas diff --git a/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs b/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs index 9068025ee..14a6e5095 100644 --- a/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs +++ b/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs @@ -183,6 +183,7 @@ impl WidgetHost { if self.editor_state.editor_ui.collab_join_input_active() { if op_editor_ui::widgets::collab_ui::join_address_select_all( &mut self.editor_state.editor_ui, + self.now_ms, ) == Some(true) { self.mark_dirty(); diff --git a/crates/op-host-web/src/widget_host/keyboard_ime.rs b/crates/op-host-web/src/widget_host/keyboard_ime.rs index d7e045daf..276ae4f8c 100644 --- a/crates/op-host-web/src/widget_host/keyboard_ime.rs +++ b/crates/op-host-web/src/widget_host/keyboard_ime.rs @@ -106,6 +106,7 @@ impl WidgetHost { let changed = op_editor_ui::widgets::collab_ui::join_address_paste( &mut self.editor_state.editor_ui, text, + self.now_ms, ) .unwrap_or(false); if changed { diff --git a/crates/op-host-web/src/widget_host/paint.rs b/crates/op-host-web/src/widget_host/paint.rs index 39007359f..a6f4cd8ba 100644 --- a/crates/op-host-web/src/widget_host/paint.rs +++ b/crates/op-host-web/src/widget_host/paint.rs @@ -340,9 +340,10 @@ impl WidgetHost { // Shared collaboration popover. Web builds normally keep the // capability unavailable for M1, but the real surface is present for // future satellite hosts and never reaches native transport APIs. - if let Some(panel) = - op_editor_ui::widgets::CollabPanel::for_editor_ui(&self.editor_state.editor_ui) - { + if let Some(panel) = op_editor_ui::widgets::CollabPanel::for_editor_ui_at( + &self.editor_state.editor_ui, + self.now_ms, + ) { let top_bar = op_editor_ui::widgets::TopBar::for_editor_ui(&self.editor_state.editor_ui) .with_traffic_controls(false);