refactor(editor): move the collab join field onto the unified text input

The join field was a bare String with a hand-rolled whole-field-selection
flag and no caret at all. It now rides TextInputState + TextInputView like
every other chrome input: blinking caret, real selection highlight, and
forward deletion for free. The field joins the active_text_input resolver
so the caret blink drives the shared redraw wake-up, and the bespoke
join_address_selected flag (a repeated source of stale-selection bugs)
is gone.
This commit is contained in:
Kayshen-X 2026-08-02 08:53:19 +08:00
parent 8654c04b00
commit ecebc3e748
21 changed files with 214 additions and 200 deletions

View file

@ -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"));

View file

@ -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<CollabPanelHover>,
pub discovered: Arc<Vec<DiscoveredCollabEndpoint>>,
}

View file

@ -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)
}

View file

@ -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);
}

View file

@ -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> {
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<Self> {
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) {

View file

@ -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<Rect> {
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);

View file

@ -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());

View file

@ -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<bool> {
pub fn join_address_text(ui: &mut EditorUiState, character: char, now_ms: u64) -> Option<bool> {
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<bool> {
pub fn join_address_backspace(ui: &mut EditorUiState, now_ms: u64) -> Option<bool> {
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<bool> {
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<bool> {
/// 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<bool> {
pub fn join_address_select_all(ui: &mut EditorUiState, now_ms: u64) -> Option<bool> {
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<bool> {
pub fn join_address_paste(ui: &mut EditorUiState, text: &str, now_ms: u64) -> Option<bool> {
if !ui.collab.panel.join_address_focused {
return None;
}
@ -620,8 +623,9 @@ pub fn join_address_paste(ui: &mut EditorUiState, text: &str) -> Option<bool> {
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<bool> {
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<bool> {
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))
}

View file

@ -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(),

View file

@ -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()
);
}

View file

@ -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());

View file

@ -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 {

View file

@ -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 {

View file

@ -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

View file

@ -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,

View file

@ -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();

View file

@ -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());
}

View file

@ -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

View file

@ -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();

View file

@ -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 {

View file

@ -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);