refactor(editor): centralize text input blink scheduling

This commit is contained in:
Kayshen-X 2026-06-14 00:56:32 +08:00
parent a1918303de
commit ea22bad6f6
9 changed files with 195 additions and 115 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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