diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index d5a23d1dc..b725f2bad 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -457,12 +457,12 @@ pub enum CloneField { /// `GitPanelCloneForm`). Reached from the empty-state Clone card. Plain /// data so the widget layer stays wasm-clean; the desktop host owns the /// folder picker + the `git clone` job. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct CloneFormState { /// Remote URL draft. - pub url: String, + pub url_input: jian_core::text_input::TextInputState, /// Local destination-folder draft. - pub dest: String, + pub dest_input: jian_core::text_input::TextInputState, /// Which field has keyboard focus (`None` = no caret). pub focus: Option, /// `true` while the `git clone` worker runs — disables the form. @@ -470,12 +470,6 @@ pub struct CloneFormState { /// Last clone error (validation or a failed `git clone`), shown /// under the fields. pub error: Option, - /// Caret-blink anchor for the focused field — same cadence as the - /// commit input. - pub caret_anchor_ms: u64, - /// True after Cmd/Ctrl+A in the focused clone input. The next edit - /// replaces the whole focused field. - pub input_select_all: bool, } /// Git panel state — a plain-data snapshot the desktop host fills @@ -657,7 +651,10 @@ impl GitPanelState { .clone_form .as_mut() .map(|form| { - form.input_select_all = false; + let url_caret = form.url_input.caret(); + form.url_input.set_caret(url_caret, 0); + let dest_caret = form.dest_input.caret(); + form.dest_input.set_caret(dest_caret, 0); form.focus.take().is_some() }) .unwrap_or(false); diff --git a/crates/op-editor-ui/src/widgets/git_panel_clone.rs b/crates/op-editor-ui/src/widgets/git_panel_clone.rs index e667c680b..27e11bb31 100644 --- a/crates/op-editor-ui/src/widgets/git_panel_clone.rs +++ b/crates/op-editor-ui/src/widgets/git_panel_clone.rs @@ -91,10 +91,11 @@ impl GitPanel<'_> { /// `true` when the form carries both a URL + destination and is not /// already cloning — gates the Clone button. fn clone_can_submit(&self) -> bool { - self.state - .clone_form - .as_ref() - .is_some_and(|f| !f.cloning && !f.url.trim().is_empty() && !f.dest.trim().is_empty()) + self.state.clone_form.as_ref().is_some_and(|f| { + !f.cloning + && !f.url_input.text().trim().is_empty() + && !f.dest_input.text().trim().is_empty() + }) } /// Paint the inline clone wizard. @@ -133,14 +134,16 @@ impl GitPanel<'_> { 11.0, t.muted_foreground, ); - self.paint_clone_input( + let url_focused = form.focus == Some(CloneField::Url) && !form.cloning; + self.paint_clone_input_frame(cx, layout.url_input, url_focused); + self.paint_text_input_view( cx, layout.url_input, - &form.url, + &form.url_input, self.t("git.wizard.clone.urlPlaceholder"), - form.focus == Some(CloneField::Url) && !form.cloning, - form.input_select_all, - form.caret_anchor_ms, + url_focused, + 12.0, + 10.0, ); // Destination field + folder-pick button. self.text( @@ -151,14 +154,16 @@ impl GitPanel<'_> { 11.0, t.muted_foreground, ); - self.paint_clone_input( + let dest_focused = form.focus == Some(CloneField::Dest) && !form.cloning; + self.paint_clone_input_frame(cx, layout.dest_input, dest_focused); + self.paint_text_input_view( cx, layout.dest_input, - &form.dest, + &form.dest_input, self.t("git.wizard.clone.destPlaceholder"), - form.focus == Some(CloneField::Dest) && !form.cloning, - form.input_select_all, - form.caret_anchor_ms, + dest_focused, + 12.0, + 10.0, ); self.paint_button_with_hit( cx, @@ -192,19 +197,7 @@ impl GitPanel<'_> { ); } - /// One bordered text field with a placeholder + blinking caret — - /// same visual language as the ready-view commit box. - #[allow(clippy::too_many_arguments)] - fn paint_clone_input( - &self, - cx: &mut PaintCx<'_>, - rect: Rect, - value: &str, - placeholder: &str, - focused: bool, - select_all: bool, - anchor_ms: u64, - ) { + fn paint_clone_input_frame(&self, cx: &mut PaintCx<'_>, rect: Rect, focused: bool) { let t = self.theme; cx.backend.fill_round_rect(rect, 6.0, t.card); let border = if focused { @@ -213,46 +206,6 @@ impl GitPanel<'_> { alpha(t.border, 0.7) }; cx.backend.stroke_round_rect(rect, 6.0, border, 1.0); - let text_x = rect.origin.x + 10.0; - let baseline = rect.origin.y + rect.size.y / 2.0 + 4.0; - if value.is_empty() && !focused { - self.text( - cx, - placeholder, - text_x, - baseline, - 12.0, - alpha(t.muted_foreground, 0.7), - ); - return; - } - // A long URL / path can exceed the field — clip so it never - // bleeds past the border. - cx.backend.save(); - cx.backend.clip_rect(rect); - if focused && select_all && !value.is_empty() { - crate::widgets::text_selection::paint_single_line_selection( - cx, - &t, - value, - text_x, - baseline, - 12.0, - rect.origin.x + rect.size.x - 10.0, - ); - } - self.text(cx, value, text_x, baseline, 12.0, t.foreground); - if focused && jian_core::anim::blink_visible(self.now_ms, anchor_ms, 500) { - let caret_x = text_x + cx.backend.measure_text(value, 12.0) + 1.0; - cx.backend.fill_rect( - Rect { - origin: Point2D::new(caret_x, rect.origin.y + 8.0), - size: Point2D::new(1.5, rect.size.y - 16.0), - }, - t.foreground, - ); - } - cx.backend.restore(); } /// Map a press inside the clone view onto a [`GitPanelHit`]. diff --git a/crates/op-editor-ui/src/widgets/git_panel_tests.rs b/crates/op-editor-ui/src/widgets/git_panel_tests.rs index f2d4915c0..b37eafa33 100644 --- a/crates/op-editor-ui/src/widgets/git_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/git_panel_tests.rs @@ -785,8 +785,10 @@ fn clone_form_takes_over_and_maps_each_target() { open: true, in_repo: false, clone_form: Some(CloneFormState { - url: "https://github.com/owner/repo.git".into(), - dest: "/tmp/repo".into(), + url_input: jian_core::text_input::TextInputState::with_text( + "https://github.com/owner/repo.git", + ), + dest_input: jian_core::text_input::TextInputState::with_text("/tmp/repo"), focus: Some(CloneField::Url), ..Default::default() }), @@ -833,8 +835,10 @@ fn clone_view_locks_to_cancel_only_while_cloning() { open: true, in_repo: false, clone_form: Some(CloneFormState { - url: "https://github.com/owner/repo.git".into(), - dest: "/tmp/repo".into(), + url_input: jian_core::text_input::TextInputState::with_text( + "https://github.com/owner/repo.git", + ), + dest_input: jian_core::text_input::TextInputState::with_text("/tmp/repo"), cloning: true, ..Default::default() }), diff --git a/crates/op-host-desktop/src/git_host.rs b/crates/op-host-desktop/src/git_host.rs index 8fbb1fb59..d11a756ee 100644 --- a/crates/op-host-desktop/src/git_host.rs +++ b/crates/op-host-desktop/src/git_host.rs @@ -771,7 +771,7 @@ impl DesktopApp { } /// Clone wizard "浏览…" — a native folder picker for the clone - /// destination, written back into the form's `dest` field. + /// destination, written back into the form's destination input. fn pick_clone_dest(&mut self) { let Some(folder) = rfd::FileDialog::new().pick_folder() else { return; @@ -785,7 +785,7 @@ impl DesktopApp { .clone_form .as_mut() { - form.dest = path; + form.dest_input.set_text(path); form.focus = Some(op_editor_core::CloneField::Dest); form.error = None; } @@ -810,7 +810,10 @@ impl DesktopApp { else { return; }; - (form.url.trim().to_string(), form.dest.trim().to_string()) + ( + form.url_input.text().trim().to_string(), + form.dest_input.text().trim().to_string(), + ) }; let validation = if url.is_empty() { Some("git.wizard.clone.validationUrl") diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index 8ade14250..fa12eb3d5 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -954,12 +954,17 @@ impl WidgetHostNative { // the loop ticking so the caret blinks and `poll_git_clone_job` // drains the worker's result on a later frame. if let Some(form) = &self.editor_state.editor_ui.git_panel.clone_form { - if form.focus.is_some() || form.cloning { - return Some(jian_core::anim::next_blink_flip_ms( - self.now_ms, - form.caret_anchor_ms, - 500, - )); + match form.focus { + Some(op_editor_core::CloneField::Url) => { + return Some(form.url_input.next_blink_flip_ms(self.now_ms)); + } + Some(op_editor_core::CloneField::Dest) => { + return Some(form.dest_input.next_blink_flip_ms(self.now_ms)); + } + None if form.cloning => { + return Some(self.now_ms + jian_core::text_input::CARET_BLINK_PERIOD_MS); + } + None => {} } } None diff --git a/crates/op-host-native/src/widget_host/blur_inputs_tests.rs b/crates/op-host-native/src/widget_host/blur_inputs_tests.rs index 6adab1e6a..9ebbfbb13 100644 --- a/crates/op-host-native/src/widget_host/blur_inputs_tests.rs +++ b/crates/op-host-native/src/widget_host/blur_inputs_tests.rs @@ -61,12 +61,15 @@ fn canvas_press_defocuses_every_git_input() { git.author_name_focused = true; git.author_email_focused = true; git.branch_create_focused = true; - git.clone_form = Some(CloneFormState { - url: "https://example.com/repo.git".into(), - dest: "/tmp/repo".into(), + let mut clone_form = CloneFormState { focus: Some(CloneField::Url), ..Default::default() - }); + }; + clone_form + .url_input + .set_text("https://example.com/repo.git"); + clone_form.dest_input.set_text("/tmp/repo"); + git.clone_form = Some(clone_form); } // Empty canvas — right of the chat panel (which floats bottom-left, diff --git a/crates/op-host-native/src/widget_host/git_panel_placement_tests.rs b/crates/op-host-native/src/widget_host/git_panel_placement_tests.rs index 8b2953d06..9c0ffafe8 100644 --- a/crates/op-host-native/src/widget_host/git_panel_placement_tests.rs +++ b/crates/op-host-native/src/widget_host/git_panel_placement_tests.rs @@ -391,7 +391,8 @@ fn clone_wizard_owns_keyboard_and_enter() { .clone_form .as_ref() .unwrap() - .url, + .url_input + .text(), "http" ); // Enter on a focused field requests the clone. @@ -435,7 +436,8 @@ fn hidden_clone_form_does_not_capture_keyboard() { .clone_form .as_ref() .unwrap() - .url, + .url_input + .text(), "", "a hidden clone form must not capture keystrokes" ); @@ -482,7 +484,35 @@ fn clone_wizard_accepts_pasted_url() { .clone_form .as_ref() .unwrap() - .url, + .url_input + .text(), "https://github.com/owner/repo.git" ); } + +#[test] +fn clone_wizard_select_all_replaces_only_the_focused_field() { + use op_editor_core::{CloneField, CloneFormState}; + let mut host = host_with_git_panel_open(); + let mut form = CloneFormState { + focus: Some(CloneField::Dest), + ..Default::default() + }; + form.url_input.set_text("https://github.com/owner/repo.git"); + form.dest_input.set_text("/tmp/repo"); + host.editor_state_mut().editor_ui.git_panel.clone_form = Some(form); + + assert!(host.apply_select_all()); + assert!(host.apply_text('x')); + + let form = host + .editor_state() + .editor_ui + .git_panel + .clone_form + .as_ref() + .unwrap(); + assert_eq!(form.url_input.text(), "https://github.com/owner/repo.git"); + assert_eq!(form.dest_input.text(), "x"); + assert_eq!(form.dest_input.caret(), 1); +} diff --git a/crates/op-host-native/src/widget_host/git_press.rs b/crates/op-host-native/src/widget_host/git_press.rs index 79834786c..bf28847b5 100644 --- a/crates/op-host-native/src/widget_host/git_press.rs +++ b/crates/op-host-native/src/widget_host/git_press.rs @@ -149,14 +149,14 @@ impl WidgetHostNative { Some(GitPanelHit::CloneUrlInput) => { if let Some(form) = panel.clone_form.as_mut() { form.focus = Some(CloneField::Url); - form.caret_anchor_ms = now; + form.url_input.touch(now); form.error = None; } } Some(GitPanelHit::CloneDestInput) => { if let Some(form) = panel.clone_form.as_mut() { form.focus = Some(CloneField::Dest); - form.caret_anchor_ms = now; + form.dest_input.touch(now); form.error = None; } } diff --git a/crates/op-host-native/src/widget_host/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index 883982ed6..0a1e26bf2 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -54,20 +54,16 @@ impl WidgetHostNative { let now = self.now_ms; if let Some(form) = self.editor_state.editor_ui.git_panel.clone_form.as_mut() { if !form.cloning { - if form.input_select_all { - match form.focus { - Some(op_editor_core::CloneField::Url) => form.url.clear(), - Some(op_editor_core::CloneField::Dest) => form.dest.clear(), - None => {} - } - form.input_select_all = false; - } + let mut s = [0u8; 4]; match form.focus { - Some(op_editor_core::CloneField::Url) => form.url.push(c), - Some(op_editor_core::CloneField::Dest) => form.dest.push(c), + Some(op_editor_core::CloneField::Url) => { + form.url_input.insert_str(c.encode_utf8(&mut s), now) + } + Some(op_editor_core::CloneField::Dest) => { + form.dest_input.insert_str(c.encode_utf8(&mut s), now) + } None => {} } - form.caret_anchor_ms = now; form.error = None; } } @@ -452,19 +448,14 @@ impl WidgetHostNative { // focused field that isn't mid-clone. if let Some(form) = self.editor_state.editor_ui.git_panel.clone_form.as_mut() { if !form.cloning { - if form.input_select_all { - match form.focus { - Some(op_editor_core::CloneField::Url) => form.url.clear(), - Some(op_editor_core::CloneField::Dest) => form.dest.clear(), - None => {} + match form.focus { + Some(op_editor_core::CloneField::Url) => { + form.url_input.backspace(self.now_ms) } - form.input_select_all = false; - } else { - match form.focus { - Some(op_editor_core::CloneField::Url) => form.url.pop(), - Some(op_editor_core::CloneField::Dest) => form.dest.pop(), - None => None, - }; + Some(op_editor_core::CloneField::Dest) => { + form.dest_input.backspace(self.now_ms) + } + None => {} } form.error = None; } @@ -1358,7 +1349,10 @@ impl WidgetHostNative { .clone_form .as_mut() .unwrap(); - form.input_select_all = false; + let url_caret = form.url_input.caret(); + form.url_input.set_caret(url_caret, self.now_ms); + let dest_caret = form.dest_input.caret(); + form.dest_input.set_caret(dest_caret, self.now_ms); form.focus.take().is_some() }; if !defocused { diff --git a/crates/op-host-native/src/widget_host/shortcuts.rs b/crates/op-host-native/src/widget_host/shortcuts.rs index 4e8e50eeb..fa33a4b9f 100644 --- a/crates/op-host-native/src/widget_host/shortcuts.rs +++ b/crates/op-host-native/src/widget_host/shortcuts.rs @@ -96,11 +96,20 @@ impl WidgetHostNative { } if self.git_clone_input_active() { if let Some(form) = self.editor_state.editor_ui.git_panel.clone_form.as_mut() { - if form.focus.is_some() { - form.input_select_all = true; - form.caret_anchor_ms = self.now_ms; - self.mark_dirty(); - return true; + match form.focus { + Some(op_editor_core::CloneField::Url) => { + form.url_input.select_all(); + form.url_input.touch(self.now_ms); + self.mark_dirty(); + return true; + } + Some(op_editor_core::CloneField::Dest) => { + form.dest_input.select_all(); + form.dest_input.touch(self.now_ms); + self.mark_dirty(); + return true; + } + None => {} } } return false;