From ea22bad6f647ec150d93a25df6e7c70afb5ceddd Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 14 Jun 2026 00:56:32 +0800 Subject: [PATCH] refactor(editor): centralize text input blink scheduling --- crates/op-editor-core/src/lib.rs | 1 + crates/op-editor-core/src/state.rs | 27 ++++ crates/op-editor-core/src/text_input_focus.rs | 126 ++++++++++++++++++ .../src/widgets/property_panel.rs | 8 +- .../src/widgets/property_panel_sections.rs | 9 +- .../src/widgets/variables_panel.rs | 9 +- .../src/widgets/variables_panel/paint.rs | 4 +- crates/op-host-native/src/widget_host.rs | 105 ++------------- .../src/widget_host/settings_caret_tests.rs | 21 +++ 9 files changed, 195 insertions(+), 115 deletions(-) create mode 100644 crates/op-editor-core/src/text_input_focus.rs diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index 37a634f3b..3d0bccf72 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -69,6 +69,7 @@ pub mod svg_import; pub mod svg_path_bounds; mod svg_path_data; pub mod text_edit; +pub mod text_input_focus; pub mod theme_presets; pub mod tool; pub mod toolbar_state; diff --git a/crates/op-editor-core/src/state.rs b/crates/op-editor-core/src/state.rs index f804bdf35..152ec1748 100644 --- a/crates/op-editor-core/src/state.rs +++ b/crates/op-editor-core/src/state.rs @@ -294,6 +294,33 @@ mod tests { assert_eq!(a.tool, b.tool); } + #[test] + fn active_text_input_prefers_canvas_text_edit_over_other_focus() { + let mut s = EditorState::new(); + s.ui.property_focus = Some(crate::ui_draft::PropertyFocus::PositionX); + s.ui.property_input.set_text("property"); + s.chat.focused = true; + s.chat.input.set_text("chat"); + s.ui.text_editing = Some(crate::NodeId::new("text")); + s.ui.text_edit_input.set_text("canvas"); + + assert_eq!( + s.active_text_input().map(|input| input.text()), + Some("canvas") + ); + } + + #[test] + fn active_text_input_mut_updates_the_focused_variable_row_input() { + let mut s = EditorState::new(); + s.editor_ui.variable_row_focus = Some(crate::editor_ui_state::VariableRowFocus::String(0)); + s.editor_ui.variable_row_input.set_text("row"); + + s.active_text_input_mut().unwrap().insert_str("!", 0); + + assert_eq!(s.editor_ui.variable_row_input.text(), "row!"); + } + #[test] fn replace_document_swaps_doc_but_preserves_editor_chrome() { let mut s = EditorState::new(); diff --git a/crates/op-editor-core/src/text_input_focus.rs b/crates/op-editor-core/src/text_input_focus.rs new file mode 100644 index 000000000..42472df45 --- /dev/null +++ b/crates/op-editor-core/src/text_input_focus.rs @@ -0,0 +1,126 @@ +//! Focused text-input resolver shared by hosts. +//! +//! The priority order mirrors the native host's historical caret +//! wake-up branches. It is intentionally explicit: when multiple +//! stale focus flags coexist, the first active site wins. + +use crate::editor_ui_state::CloneField; +use crate::state::EditorState; +use jian_core::text_input::TextInputState; + +impl EditorState { + pub fn active_text_input(&self) -> Option<&TextInputState> { + if self.ui.text_editing.is_some() { + return Some(&self.ui.text_edit_input); + } + if let Some(rename) = &self.ui.layer_rename { + return Some(&rename.input); + } + if self.ui.property_focus.is_some() { + return Some(&self.ui.property_input); + } + if self.editor_ui.variables_theme_rename_axis.is_some() + || self.editor_ui.variables_variant_rename_value.is_some() + { + return Some(&self.editor_ui.variables_header_input); + } + if self.editor_ui.variable_row_focus.is_some() { + return Some(&self.editor_ui.variable_row_input); + } + if self.editor_ui.agent_settings_open && self.editor_ui.agent_settings.focus.is_some() { + return Some(&self.editor_ui.settings_input); + } + if self.editor_ui.chat_model_picker_open { + return Some(&self.editor_ui.chat_model_picker_input); + } + if self.chat.focused { + return Some(&self.chat.input); + } + + let git = &self.editor_ui.git_panel; + if git.commit_focused { + return Some(&git.commit_input); + } + if git.remote_focused { + return Some(&git.remote_input); + } + if git.https_focused { + return Some(&git.https_input); + } + if git.branch_create_focused { + return Some(&git.branch_create_input); + } + if git.author_name_focused { + return Some(&git.author_name_input); + } + if git.author_email_focused { + return Some(&git.author_email_input); + } + if let Some(form) = &git.clone_form { + return match form.focus { + Some(CloneField::Url) => Some(&form.url_input), + Some(CloneField::Dest) => Some(&form.dest_input), + None => None, + }; + } + None + } + + pub fn active_text_input_mut(&mut self) -> Option<&mut TextInputState> { + if self.ui.text_editing.is_some() { + return Some(&mut self.ui.text_edit_input); + } + if let Some(rename) = &mut self.ui.layer_rename { + return Some(&mut rename.input); + } + if self.ui.property_focus.is_some() { + return Some(&mut self.ui.property_input); + } + + let variables_header_active = self.editor_ui.variables_theme_rename_axis.is_some() + || self.editor_ui.variables_variant_rename_value.is_some(); + if variables_header_active { + return Some(&mut self.editor_ui.variables_header_input); + } + if self.editor_ui.variable_row_focus.is_some() { + return Some(&mut self.editor_ui.variable_row_input); + } + if self.editor_ui.agent_settings_open && self.editor_ui.agent_settings.focus.is_some() { + return Some(&mut self.editor_ui.settings_input); + } + if self.editor_ui.chat_model_picker_open { + return Some(&mut self.editor_ui.chat_model_picker_input); + } + if self.chat.focused { + return Some(&mut self.chat.input); + } + + let git = &mut self.editor_ui.git_panel; + if git.commit_focused { + return Some(&mut git.commit_input); + } + if git.remote_focused { + return Some(&mut git.remote_input); + } + if git.https_focused { + return Some(&mut git.https_input); + } + if git.branch_create_focused { + return Some(&mut git.branch_create_input); + } + if git.author_name_focused { + return Some(&mut git.author_name_input); + } + if git.author_email_focused { + return Some(&mut git.author_email_input); + } + if let Some(form) = &mut git.clone_form { + return match form.focus { + Some(CloneField::Url) => Some(&mut form.url_input), + Some(CloneField::Dest) => Some(&mut form.dest_input), + None => None, + }; + } + None + } +} diff --git a/crates/op-editor-ui/src/widgets/property_panel.rs b/crates/op-editor-ui/src/widgets/property_panel.rs index 22169d209..6c327be83 100644 --- a/crates/op-editor-ui/src/widgets/property_panel.rs +++ b/crates/op-editor-ui/src/widgets/property_panel.rs @@ -116,11 +116,7 @@ pub struct PropertyPanel { pub caret_pos: usize, /// Whether Ctrl/Cmd+A selected the full focused draft. pub select_all: bool, - /// Caret-blink anchor (ms since host start) for the focused - /// input. Drives the same `jian_core::anim::blink_visible` - /// helper the chat caret uses. - pub caret_anchor_ms: u64, - /// Host clock ms; paired with `caret_anchor_ms` for caret blink. + /// Host clock ms for caret blink. pub now_ms: u64, /// Active flex-layout button. pub flex_layout: op_editor_core::FlexLayout, @@ -371,7 +367,6 @@ impl PropertyPanel { state.ui.property_input.caret() }, select_all: !is_multi && state.ui.property_input.is_select_all(), - caret_anchor_ms: state.ui.property_caret_anchor_ms, now_ms, flex_layout, size_flags, @@ -810,7 +805,6 @@ impl Widget for PropertyPanel { input: &self.input, caret: self.caret_pos, select_all: self.select_all, - caret_anchor_ms: self.caret_anchor_ms, now_ms: self.now_ms, }; let caps = self.capabilities(); diff --git a/crates/op-editor-ui/src/widgets/property_panel_sections.rs b/crates/op-editor-ui/src/widgets/property_panel_sections.rs index cebef6ffa..ed78bc065 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_sections.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_sections.rs @@ -52,7 +52,6 @@ pub struct EditContext<'a> { pub caret: usize, /// Whether Ctrl/Cmd+A selected the focused draft. pub select_all: bool, - pub caret_anchor_ms: u64, pub now_ms: u64, } @@ -156,17 +155,15 @@ impl<'a> EditContext<'a> { /// for editable surfaces (effect params) that don't key off a /// `PropertyFocus`. pub fn caret_blink_on(&self) -> bool { - jian_core::anim::blink_visible(self.now_ms, self.caret_anchor_ms, 500) + self.input.caret_visible(self.now_ms) } /// Caret byte-offset for `focus` when it is the focused field /// and the blink is on — `None` otherwise. Drives caret paint; /// the offset is clamped into the draft so a stale value is safe. pub fn caret_at(&self, focus: PropertyFocus) -> Option { - if self.focus == Some(focus) - && jian_core::anim::blink_visible(self.now_ms, self.caret_anchor_ms, 500) - { - Some(self.caret.min(self.draft.len())) + if self.focus == Some(focus) && self.input.caret_visible(self.now_ms) { + Some(self.input.caret().min(self.draft.len())) } else { None } diff --git a/crates/op-editor-ui/src/widgets/variables_panel.rs b/crates/op-editor-ui/src/widgets/variables_panel.rs index 76bc81828..13e03668c 100644 --- a/crates/op-editor-ui/src/widgets/variables_panel.rs +++ b/crates/op-editor-ui/src/widgets/variables_panel.rs @@ -141,6 +141,7 @@ pub struct VariablesPanel { add_menu_open: bool, search: String, search_focus: bool, + search_input: jian_core::text_input::TextInputState, scroll: f32, /// Open `⋯` row menu, keyed by UNFILTERED row index. row_menu_open: Option, @@ -149,7 +150,6 @@ pub struct VariablesPanel { editing_value_cell: Option<(usize, usize)>, header_input: jian_core::text_input::TextInputState, row_input: jian_core::text_input::TextInputState, - caret_anchor_ms: u64, now_ms: u64, } @@ -228,6 +228,11 @@ impl VariablesPanel { .cloned() }) .or_else(|| themes.first().map(|(axis, _)| axis.clone())); + let mut search_input = jian_core::text_input::TextInputState::with_text( + state.editor_ui.variables_search.clone(), + ); + search_input.touch(state.ui.property_caret_anchor_ms); + Self { rows, total_rows, @@ -245,6 +250,7 @@ impl VariablesPanel { add_menu_open: state.editor_ui.variables_add_menu_open, search: state.editor_ui.variables_search.clone(), search_focus: state.editor_ui.variables_search_focus, + search_input, scroll: state.editor_ui.variables_scroll, row_menu_open: state.editor_ui.variables_row_menu, hover: state.editor_ui.variables_panel_hover, @@ -265,7 +271,6 @@ impl VariablesPanel { }), header_input: state.editor_ui.variables_header_input.clone(), row_input: state.editor_ui.variable_row_input.clone(), - caret_anchor_ms: state.ui.property_caret_anchor_ms, now_ms, } } diff --git a/crates/op-editor-ui/src/widgets/variables_panel/paint.rs b/crates/op-editor-ui/src/widgets/variables_panel/paint.rs index 08a83a177..9b5fecd2e 100644 --- a/crates/op-editor-ui/src/widgets/variables_panel/paint.rs +++ b/crates/op-editor-ui/src/widgets/variables_panel/paint.rs @@ -87,9 +87,7 @@ fn paint_search_row( baseline_y, ); } - if panel.search_focus - && jian_core::anim::blink_visible(panel.now_ms, panel.caret_anchor_ms, 500) - { + if panel.search_focus && panel.search_input.caret_visible(panel.now_ms) { let caret_x = text_x + if panel.search.is_empty() { 0.0 diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index eba624102..7f1d39dad 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -282,8 +282,8 @@ pub struct WidgetHostNative { /// Bumped past the highest sample id so new + sample nodes /// never collide on the same key. pub(in crate::widget_host) next_node_id: u64, - /// Host-supplied frame timestamp in milliseconds. Drives the - /// caret blink via `jian_core::anim::blink_visible`. The + /// Host-supplied frame timestamp in milliseconds. Focused + /// `TextInputState`s use this for caret blink. The /// inspector_window runner refreshes this once per /// `RedrawRequested` from a single `Instant` start anchor; /// any other host (mobile / browser) installs its own clock. @@ -864,103 +864,14 @@ impl WidgetHostNative { /// Next millisecond at which the host should wake to repaint /// the caret blink phase. `None` = no animation pending. pub fn next_animation_deadline_ms(&self) -> Option { - let ui = &self.editor_state.ui; - if ui.text_editing.is_some() { - return Some(ui.text_edit_input.next_blink_flip_ms(self.now_ms)); + if let Some(input) = self.editor_state.active_text_input() { + return Some(input.next_blink_flip_ms(self.now_ms)); } - if let Some(rename) = &ui.layer_rename { - return Some(rename.input.next_blink_flip_ms(self.now_ms)); - } - if ui.property_focus.is_some() { - return Some(jian_core::anim::next_blink_flip_ms( - self.now_ms, - ui.property_caret_anchor_ms, - 500, - )); - } - if self - .editor_state - .editor_ui - .variables_theme_rename_axis - .is_some() - || self - .editor_state - .editor_ui - .variables_variant_rename_value - .is_some() - || self.editor_state.editor_ui.variable_row_focus.is_some() - { - return Some(jian_core::anim::next_blink_flip_ms( - self.now_ms, - ui.property_caret_anchor_ms, - 500, - )); - } - if self.editor_state.editor_ui.agent_settings_open - && self.editor_state.editor_ui.agent_settings.focus.is_some() - { - return Some( - self.editor_state - .editor_ui - .settings_input - .next_blink_flip_ms(self.now_ms), - ); - } - if self.editor_state.editor_ui.chat_model_picker_open { - return Some( - self.editor_state - .editor_ui - .chat_model_picker_input - .next_blink_flip_ms(self.now_ms), - ); - } - if self.editor_state.chat.focused { - return Some(self.editor_state.chat.input.next_blink_flip_ms(self.now_ms)); - } - // Git commit textarea caret — same 500 ms cadence the ready - // panel paints at (`git_panel_ready.rs`). Without this wake the - // window never repaints while the commit box is focused, so the - // caret sits static instead of blinking. - if self.editor_state.editor_ui.git_panel.commit_focused { - return Some( - self.editor_state - .editor_ui - .git_panel - .commit_input - .next_blink_flip_ms(self.now_ms), - ); - } - let git = &self.editor_state.editor_ui.git_panel; - if git.remote_focused { - return Some(git.remote_input.next_blink_flip_ms(self.now_ms)); - } - if git.https_focused { - return Some(git.https_input.next_blink_flip_ms(self.now_ms)); - } - if git.branch_create_focused { - return Some(git.branch_create_input.next_blink_flip_ms(self.now_ms)); - } - if git.author_name_focused { - return Some(git.author_name_input.next_blink_flip_ms(self.now_ms)); - } - if git.author_email_focused { - return Some(git.author_email_input.next_blink_flip_ms(self.now_ms)); - } - // Clone-wizard field caret, and while a `git clone` runs — keep - // the loop ticking so the caret blinks and `poll_git_clone_job` - // drains the worker's result on a later frame. + // While a `git clone` runs, keep the loop ticking so + // `poll_git_clone_job` drains the worker's result later. if let Some(form) = &self.editor_state.editor_ui.git_panel.clone_form { - 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 => {} + if form.cloning { + return Some(self.now_ms.saturating_add(100)); } } None diff --git a/crates/op-host-native/src/widget_host/settings_caret_tests.rs b/crates/op-host-native/src/widget_host/settings_caret_tests.rs index aaede48e2..b7f0d14bc 100644 --- a/crates/op-host-native/src/widget_host/settings_caret_tests.rs +++ b/crates/op-host-native/src/widget_host/settings_caret_tests.rs @@ -1,5 +1,6 @@ use super::WidgetHostNative; use op_editor_core::agent_settings::{BuiltinAgentField, SettingsFocus}; +use op_editor_core::editor_ui_state::VariableRowFocus; #[test] fn settings_input_uses_text_input_state_for_editing() { @@ -28,3 +29,23 @@ fn settings_input_uses_text_input_state_for_editing() { assert_eq!(host.editor_state().editor_ui.settings_input.text(), "Z"); assert_eq!(host.editor_state().editor_ui.settings_input.caret(), 1); } + +#[test] +fn next_animation_deadline_uses_focused_variable_row_input_anchor() { + let mut host = WidgetHostNative::new(); + host.set_now_ms(1_300); + { + let state = host.editor_state_mut(); + state.editor_ui.variable_row_focus = Some(VariableRowFocus::String(0)); + state.editor_ui.variable_row_input.touch(1_250); + state.ui.property_caret_anchor_ms = 0; + } + + let expected = host + .editor_state() + .editor_ui + .variable_row_input + .next_blink_flip_ms(1_300); + + assert_eq!(host.next_animation_deadline_ms(), Some(expected)); +}