mirror of
https://github.com/openai/codex.git
synced 2026-05-18 12:18:32 +03:00
Compare commits
1 Commits
dev/cc/new
...
fcoury/app
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01f639fdf8 |
@@ -69,6 +69,7 @@ use crate::mention_codec::encode_history_mentions;
|
||||
use crate::model_catalog::ModelCatalog;
|
||||
use crate::multi_agents;
|
||||
use crate::multi_agents::AgentMetadata;
|
||||
use crate::patch_progress_animation::PATCH_PROGRESS_FRAME_MS;
|
||||
use crate::session_state::SessionNetworkProxyRuntime;
|
||||
use crate::session_state::ThreadSessionState;
|
||||
use crate::status::RateLimitWindowDisplay;
|
||||
@@ -3804,6 +3805,55 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders or updates the transient live preview for a streaming patch snapshot.
|
||||
///
|
||||
/// Patch updates are complete snapshots from the app-server, so a matching
|
||||
/// active preview is mutated in place and a mismatched preview is dropped
|
||||
/// before the new one starts. Replay events are filtered before this method
|
||||
/// is called; feeding replay snapshots here would create transient rows
|
||||
/// while restoring historical turns.
|
||||
fn on_patch_apply_updated(&mut self, call_id: String, changes: HashMap<PathBuf, FileChange>) {
|
||||
if changes.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.flush_answer_stream_with_separator();
|
||||
if self
|
||||
.active_cell
|
||||
.as_ref()
|
||||
.and_then(|cell| {
|
||||
cell.as_any()
|
||||
.downcast_ref::<history_cell::StreamingPatchHistoryCell>()
|
||||
})
|
||||
.is_some_and(|cell| cell.call_id() == call_id)
|
||||
&& let Some(cell) = self.active_cell.as_mut().and_then(|cell| {
|
||||
cell.as_any_mut()
|
||||
.downcast_mut::<history_cell::StreamingPatchHistoryCell>()
|
||||
})
|
||||
{
|
||||
cell.update(changes);
|
||||
self.bump_active_cell_revision();
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
self.flush_active_cell();
|
||||
self.active_cell = Some(Box::new(history_cell::new_active_patch_event(
|
||||
call_id,
|
||||
changes,
|
||||
&self.config.cwd,
|
||||
self.config.animations,
|
||||
)));
|
||||
self.bump_active_cell_revision();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Appends the durable patch history cell after patch execution starts.
|
||||
///
|
||||
/// The item-started notification is the point where the patch has moved
|
||||
/// from preview streaming to execution. If a live patch preview is still
|
||||
/// active, the generic active-cell flush path drops it before inserting the
|
||||
/// durable summary.
|
||||
fn on_patch_apply_begin(&mut self, changes: HashMap<PathBuf, FileChange>) {
|
||||
self.add_to_history(history_cell::new_patch_event(changes, &self.config.cwd));
|
||||
}
|
||||
@@ -4193,6 +4243,30 @@ impl ChatWidget {
|
||||
self.frame_requester.schedule_frame_in(delay);
|
||||
}
|
||||
|
||||
/// Schedules another frame while the active patch preview is animating.
|
||||
///
|
||||
/// Server notifications update the data model, but drain and fade phases
|
||||
/// also need timer-driven redraws. This intentionally looks only at the
|
||||
/// active cell because streaming patch previews are transient and should
|
||||
/// not keep animating after they are flushed to history.
|
||||
fn schedule_patch_progress_timer_if_needed(&self) {
|
||||
if self.config.animations
|
||||
&& self
|
||||
.active_cell
|
||||
.as_ref()
|
||||
.and_then(|cell| {
|
||||
cell.as_any()
|
||||
.downcast_ref::<history_cell::StreamingPatchHistoryCell>()
|
||||
})
|
||||
.is_some_and(history_cell::StreamingPatchHistoryCell::has_active_counter_animation)
|
||||
{
|
||||
self.frame_requester
|
||||
.schedule_frame_in(Duration::from_millis(
|
||||
/*millis*/ PATCH_PROGRESS_FRAME_MS,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn on_stream_error(&mut self, message: String, additional_details: Option<String>) {
|
||||
if self.retry_status_header.is_none() {
|
||||
self.retry_status_header = Some(self.current_status.header.clone());
|
||||
@@ -4210,6 +4284,7 @@ impl ChatWidget {
|
||||
pub(crate) fn pre_draw_tick(&mut self) {
|
||||
self.update_due_hook_visibility();
|
||||
self.schedule_hook_timer_if_needed();
|
||||
self.schedule_patch_progress_timer_if_needed();
|
||||
self.bottom_pane.pre_draw_tick();
|
||||
self.refresh_plan_mode_nudge();
|
||||
self.refresh_goal_status_indicator_for_time_tick();
|
||||
@@ -5532,6 +5607,14 @@ impl ChatWidget {
|
||||
|
||||
fn flush_active_cell(&mut self) {
|
||||
if let Some(active) = self.active_cell.take() {
|
||||
if active
|
||||
.as_any()
|
||||
.is::<history_cell::StreamingPatchHistoryCell>()
|
||||
{
|
||||
self.bump_active_cell_revision();
|
||||
return;
|
||||
}
|
||||
|
||||
self.needs_final_message_separator = true;
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(active));
|
||||
}
|
||||
@@ -6440,7 +6523,6 @@ impl ChatWidget {
|
||||
| ServerNotification::CommandExecOutputDelta(_)
|
||||
| ServerNotification::ProcessOutputDelta(_)
|
||||
| ServerNotification::ProcessExited(_)
|
||||
| ServerNotification::FileChangePatchUpdated(_)
|
||||
| ServerNotification::McpToolCallProgress(_)
|
||||
| ServerNotification::McpServerOauthLoginCompleted(_)
|
||||
| ServerNotification::AppListUpdated(_)
|
||||
@@ -6454,6 +6536,14 @@ impl ChatWidget {
|
||||
| ServerNotification::WindowsWorldWritableWarning(_)
|
||||
| ServerNotification::WindowsSandboxSetupCompleted(_)
|
||||
| ServerNotification::AccountLoginCompleted(_) => {}
|
||||
ServerNotification::FileChangePatchUpdated(notification) => {
|
||||
if !from_replay {
|
||||
self.on_patch_apply_updated(
|
||||
notification.item_id,
|
||||
file_update_changes_to_display(notification.changes),
|
||||
);
|
||||
}
|
||||
}
|
||||
ServerNotification::ContextCompacted(_) => {}
|
||||
}
|
||||
}
|
||||
@@ -6780,6 +6870,12 @@ impl ChatWidget {
|
||||
exec.mark_failed();
|
||||
} else if let Some(tool) = cell.as_any_mut().downcast_mut::<McpToolCallCell>() {
|
||||
tool.mark_failed();
|
||||
} else if cell
|
||||
.as_any()
|
||||
.is::<history_cell::StreamingPatchHistoryCell>()
|
||||
{
|
||||
self.bump_active_cell_revision();
|
||||
return;
|
||||
}
|
||||
self.add_boxed_history(cell);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/exec_flow.rs
|
||||
expression: second_blob
|
||||
---
|
||||
+1
|
||||
• Editing 2 files +1 -0
|
||||
-1
|
||||
└ bar.rs
|
||||
└ foo.txt
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/exec_flow.rs
|
||||
expression: single_blob
|
||||
---
|
||||
• Editing old.rs → new.rs +1 -1
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/exec_flow.rs
|
||||
expression: multi_blob
|
||||
---
|
||||
+1
|
||||
• Editing 2 files +1 -1
|
||||
└ added.txt
|
||||
└ old.rs → new.rs
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/exec_flow.rs
|
||||
expression: lines_to_single_string(&first_live)
|
||||
---
|
||||
• Creating foo.txt +1
|
||||
@@ -1426,6 +1426,205 @@ async fn apply_patch_manual_approval_adjusts_header() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_patch_streaming_updates_render_live_state_until_apply_begins() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
let mut first_changes = HashMap::new();
|
||||
first_changes.insert(
|
||||
PathBuf::from("foo.txt"),
|
||||
FileChange::Add {
|
||||
content: "hello\n".to_string(),
|
||||
},
|
||||
);
|
||||
handle_patch_apply_updated(&mut chat, "c1", "turn-c1", first_changes);
|
||||
assert!(
|
||||
drain_insert_history(&mut rx).is_empty(),
|
||||
"streaming patch previews should stay transient"
|
||||
);
|
||||
let first_live = chat
|
||||
.active_cell_transcript_lines(/*width*/ 80)
|
||||
.expect("live patch transcript lines");
|
||||
assert_chatwidget_snapshot!(
|
||||
"apply_patch_streaming_live_single_file",
|
||||
lines_to_single_string(&first_live)
|
||||
);
|
||||
|
||||
let mut second_changes = HashMap::new();
|
||||
second_changes.insert(
|
||||
PathBuf::from("foo.txt"),
|
||||
FileChange::Add {
|
||||
content: "hello\n".to_string(),
|
||||
},
|
||||
);
|
||||
second_changes.insert(
|
||||
PathBuf::from("bar.rs"),
|
||||
FileChange::Update {
|
||||
unified_diff: "@@ -1 +1 @@\n-old\n+new\n".to_string(),
|
||||
move_path: None,
|
||||
},
|
||||
);
|
||||
handle_patch_apply_updated(&mut chat, "c1", "turn-c1", second_changes.clone());
|
||||
assert!(
|
||||
drain_insert_history(&mut rx).is_empty(),
|
||||
"streaming patch updates should mutate the existing active cell"
|
||||
);
|
||||
let second_live = chat
|
||||
.active_cell_transcript_lines(/*width*/ 80)
|
||||
.expect("updated live patch transcript lines");
|
||||
let second_blob = lines_to_single_string(&second_live);
|
||||
assert_chatwidget_snapshot!("apply_patch_streaming_live_multi_file", second_blob);
|
||||
|
||||
handle_patch_apply_begin(&mut chat, "c1", "turn-c1", second_changes);
|
||||
assert!(
|
||||
chat.active_cell_transcript_lines(/*width*/ 80).is_none(),
|
||||
"the provisional live preview should clear when the real patch item starts"
|
||||
);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1, "expected only the durable patch summary");
|
||||
let durable_blob = lines_to_single_string(&cells[0]);
|
||||
assert!(durable_blob.contains("Edited 2 files"), "{durable_blob:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_patch_streaming_updates_render_rename_targets() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
let mut changes = HashMap::new();
|
||||
changes.insert(
|
||||
PathBuf::from("old.rs"),
|
||||
FileChange::Update {
|
||||
unified_diff: "@@ -1 +1 @@\n-old\n+new\n".to_string(),
|
||||
move_path: Some(PathBuf::from("new.rs")),
|
||||
},
|
||||
);
|
||||
handle_patch_apply_updated(&mut chat, "c1", "turn-c1", changes.clone());
|
||||
assert!(
|
||||
drain_insert_history(&mut rx).is_empty(),
|
||||
"streaming rename previews should stay transient"
|
||||
);
|
||||
let single_live = chat
|
||||
.active_cell_transcript_lines(/*width*/ 80)
|
||||
.expect("live rename patch transcript lines");
|
||||
let single_blob = lines_to_single_string(&single_live);
|
||||
assert!(
|
||||
single_blob.contains("Editing old.rs → new.rs"),
|
||||
"{single_blob:?}"
|
||||
);
|
||||
assert_chatwidget_snapshot!("apply_patch_streaming_live_rename", single_blob);
|
||||
|
||||
changes.insert(
|
||||
PathBuf::from("added.txt"),
|
||||
FileChange::Add {
|
||||
content: "hello\n".to_string(),
|
||||
},
|
||||
);
|
||||
handle_patch_apply_updated(&mut chat, "c1", "turn-c1", changes);
|
||||
let multi_live = chat
|
||||
.active_cell_transcript_lines(/*width*/ 80)
|
||||
.expect("live multi-file rename patch transcript lines");
|
||||
let multi_blob = lines_to_single_string(&multi_live);
|
||||
assert!(multi_blob.contains("old.rs → new.rs"), "{multi_blob:?}");
|
||||
assert_chatwidget_snapshot!("apply_patch_streaming_live_rename_multi_file", multi_blob);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_patch_interleaved_streaming_previews_stay_transient() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
let mut first_changes = HashMap::new();
|
||||
first_changes.insert(
|
||||
PathBuf::from("first.txt"),
|
||||
FileChange::Add {
|
||||
content: "first\n".to_string(),
|
||||
},
|
||||
);
|
||||
handle_patch_apply_updated(&mut chat, "c1", "turn-c1", first_changes.clone());
|
||||
assert!(
|
||||
drain_insert_history(&mut rx).is_empty(),
|
||||
"first streaming patch preview should stay transient"
|
||||
);
|
||||
|
||||
let mut second_changes = HashMap::new();
|
||||
second_changes.insert(
|
||||
PathBuf::from("second.txt"),
|
||||
FileChange::Add {
|
||||
content: "second\n".to_string(),
|
||||
},
|
||||
);
|
||||
handle_patch_apply_updated(&mut chat, "c2", "turn-c2", second_changes.clone());
|
||||
assert!(
|
||||
drain_insert_history(&mut rx).is_empty(),
|
||||
"replacing a streaming patch preview should not commit the stale preview"
|
||||
);
|
||||
|
||||
let active_blob = lines_to_single_string(
|
||||
&chat
|
||||
.active_cell_transcript_lines(/*width*/ 80)
|
||||
.expect("second live patch transcript lines"),
|
||||
);
|
||||
assert!(active_blob.contains("second.txt"), "{active_blob:?}");
|
||||
assert!(!active_blob.contains("first.txt"), "{active_blob:?}");
|
||||
|
||||
handle_patch_apply_begin(&mut chat, "c1", "turn-c1", first_changes);
|
||||
let first_durable = drain_insert_history(&mut rx);
|
||||
assert_eq!(first_durable.len(), 1);
|
||||
let first_durable_blob = lines_to_single_string(&first_durable[0]);
|
||||
assert!(
|
||||
first_durable_blob.contains("Added first.txt"),
|
||||
"{first_durable_blob:?}"
|
||||
);
|
||||
assert!(
|
||||
chat.active_cell_transcript_lines(/*width*/ 80).is_none(),
|
||||
"the second preview should be dropped before the first durable row is inserted"
|
||||
);
|
||||
|
||||
handle_patch_apply_begin(&mut chat, "c2", "turn-c2", second_changes);
|
||||
assert!(
|
||||
chat.active_cell_transcript_lines(/*width*/ 80).is_none(),
|
||||
"the matching second preview should clear when its durable patch item starts"
|
||||
);
|
||||
let second_durable = drain_insert_history(&mut rx);
|
||||
assert_eq!(second_durable.len(), 1);
|
||||
let second_durable_blob = lines_to_single_string(&second_durable[0]);
|
||||
assert!(
|
||||
second_durable_blob.contains("Added second.txt"),
|
||||
"{second_durable_blob:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_patch_streaming_preview_is_dropped_before_unrelated_history() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
let mut changes = HashMap::new();
|
||||
changes.insert(
|
||||
PathBuf::from("preview.txt"),
|
||||
FileChange::Add {
|
||||
content: "preview\n".to_string(),
|
||||
},
|
||||
);
|
||||
handle_patch_apply_updated(&mut chat, "c1", "turn-c1", changes);
|
||||
assert!(
|
||||
drain_insert_history(&mut rx).is_empty(),
|
||||
"streaming patch preview should stay transient"
|
||||
);
|
||||
|
||||
chat.add_to_history(crate::history_cell::PlainHistoryCell::new(vec![
|
||||
"unrelated history".into(),
|
||||
]));
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1);
|
||||
let blob = lines_to_single_string(&cells[0]);
|
||||
assert!(blob.contains("unrelated history"), "{blob:?}");
|
||||
assert!(!blob.contains("preview.txt"), "{blob:?}");
|
||||
assert!(
|
||||
chat.active_cell_transcript_lines(/*width*/ 80).is_none(),
|
||||
"generic active-cell flush should drop the preview"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_patch_manual_flow_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -762,6 +762,25 @@ fn file_update_changes_from_tui(changes: HashMap<PathBuf, FileChange>) -> Vec<Fi
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn handle_patch_apply_updated(
|
||||
chat: &mut ChatWidget,
|
||||
call_id: impl Into<String>,
|
||||
turn_id: impl Into<String>,
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::FileChangePatchUpdated(
|
||||
codex_app_server_protocol::FileChangePatchUpdatedNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: turn_id.into(),
|
||||
item_id: call_id.into(),
|
||||
changes: file_update_changes_from_tui(changes),
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_patch_apply_begin(
|
||||
chat: &mut ChatWidget,
|
||||
call_id: impl Into<String>,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! rendered transcript output can change.
|
||||
|
||||
use crate::diff_model::FileChange;
|
||||
use crate::diff_render::calculate_add_remove_from_diff;
|
||||
use crate::diff_render::create_diff_summary;
|
||||
use crate::diff_render::display_path_for;
|
||||
use crate::exec_cell::CommandOutput;
|
||||
@@ -25,6 +26,10 @@ use crate::markdown::append_markdown;
|
||||
use crate::motion::MotionMode;
|
||||
use crate::motion::ReducedMotionIndicator;
|
||||
use crate::motion::activity_indicator;
|
||||
use crate::patch_progress_animation::PATCH_PROGRESS_FRAME_MS;
|
||||
use crate::patch_progress_animation::PatchCounterAnim;
|
||||
use crate::patch_progress_animation::PatchCounterKind;
|
||||
use crate::patch_progress_animation::update_counter_anim;
|
||||
use crate::render::line_utils::line_to_static;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
@@ -1263,6 +1268,437 @@ impl HistoryCell for PatchHistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Transient history cell for a patch preview that is still streaming.
|
||||
///
|
||||
/// The cell is owned by ChatWidget::active_cell and never enters durable
|
||||
/// transcript history. It is keyed by the app-server file-change item id so
|
||||
/// repeated FileChangePatchUpdated notifications mutate the same live row, and
|
||||
/// ItemStarted(FileChange) can clear the preview before adding the real patch
|
||||
/// summary cell.
|
||||
///
|
||||
/// The stored changes map is always the latest complete snapshot from the
|
||||
/// app-server. Animation state is derived from differences between consecutive
|
||||
/// snapshots and may be absent when animations are disabled, when rendering raw
|
||||
/// lines, or when a snapshot decreases a counter.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct StreamingPatchHistoryCell {
|
||||
call_id: String,
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
cwd: PathBuf,
|
||||
start_time: Instant,
|
||||
animations_enabled: bool,
|
||||
added_anim: Option<PatchCounterAnim>,
|
||||
removed_anim: Option<PatchCounterAnim>,
|
||||
}
|
||||
|
||||
impl StreamingPatchHistoryCell {
|
||||
/// Returns the app-server file-change item id that owns this preview.
|
||||
///
|
||||
/// ChatWidget uses this to decide whether an update should mutate the
|
||||
/// current active cell or flush that cell and start a separate preview.
|
||||
/// Treating updates for different ids as the same cell would merge two
|
||||
/// independent patch previews into one transcript row.
|
||||
pub(crate) fn call_id(&self) -> &str {
|
||||
&self.call_id
|
||||
}
|
||||
|
||||
/// Replaces the preview with a newer complete patch snapshot.
|
||||
///
|
||||
/// The method compares line totals before replacing self.changes so the
|
||||
/// added and removed counters can animate only the newly observed increase.
|
||||
/// Callers should pass the full snapshot from the notification rather than
|
||||
/// only changed files; passing a partial snapshot would make unchanged
|
||||
/// files disappear from the live preview and could clear animations as a
|
||||
/// false decrease.
|
||||
pub(crate) fn update(&mut self, changes: HashMap<PathBuf, FileChange>) {
|
||||
let had_existing_changes = !self.changes.is_empty();
|
||||
let current_stats = streaming_patch_stats(self.changes.values());
|
||||
let next_stats = streaming_patch_stats(changes.values());
|
||||
let now = Instant::now();
|
||||
update_counter_anim(
|
||||
&mut self.added_anim,
|
||||
PatchCounterKind::Added,
|
||||
current_stats.added,
|
||||
next_stats.added,
|
||||
had_existing_changes,
|
||||
self.animations_enabled,
|
||||
now,
|
||||
);
|
||||
update_counter_anim(
|
||||
&mut self.removed_anim,
|
||||
PatchCounterKind::Removed,
|
||||
current_stats.removed,
|
||||
next_stats.removed,
|
||||
had_existing_changes,
|
||||
self.animations_enabled,
|
||||
now,
|
||||
);
|
||||
self.changes = changes;
|
||||
}
|
||||
|
||||
fn render_lines(
|
||||
&self,
|
||||
bullet: Span<'static>,
|
||||
include_counter_animation: bool,
|
||||
) -> Vec<Line<'static>> {
|
||||
let mut paths: Vec<_> = self.changes.iter().collect();
|
||||
paths.sort_by_key(|(path, _)| *path);
|
||||
|
||||
let file_count = paths.len();
|
||||
let actual_stats = streaming_patch_stats(paths.iter().map(|(_, change)| *change));
|
||||
let animated = include_counter_animation && self.animations_enabled;
|
||||
let stats_render = patch_stats_render(
|
||||
actual_stats,
|
||||
if animated {
|
||||
self.added_anim.as_ref()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if animated {
|
||||
self.removed_anim.as_ref()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
let mut lines = Vec::new();
|
||||
let (mut header_spans, header_prefix_width) = if let [(path, change)] = paths.as_slice() {
|
||||
let verb = match change {
|
||||
FileChange::Add { .. } => "Creating",
|
||||
FileChange::Delete { .. } => "Deleting",
|
||||
FileChange::Update { .. } => "Editing",
|
||||
};
|
||||
let path_display = streaming_patch_path_display(path, change, &self.cwd);
|
||||
let prefix_width = span_width(&bullet)
|
||||
+ 1
|
||||
+ UnicodeWidthStr::width(verb)
|
||||
+ 1
|
||||
+ UnicodeWidthStr::width(path_display.as_str());
|
||||
(
|
||||
vec![
|
||||
bullet,
|
||||
" ".into(),
|
||||
verb.bold(),
|
||||
" ".into(),
|
||||
path_display.into(),
|
||||
],
|
||||
prefix_width,
|
||||
)
|
||||
} else {
|
||||
let noun = if file_count == 1 { "file" } else { "files" };
|
||||
let file_label = format!(" {file_count} {noun}");
|
||||
let prefix_width =
|
||||
span_width(&bullet) + 1 + UnicodeWidthStr::width("Editing") + file_label.len();
|
||||
(
|
||||
vec![bullet, " ".into(), "Editing".bold(), file_label.into()],
|
||||
prefix_width,
|
||||
)
|
||||
};
|
||||
|
||||
let counter_positions =
|
||||
stats_render.push_counter_spans(&mut header_spans, header_prefix_width);
|
||||
|
||||
if let Some(line) =
|
||||
stats_render.floating_line(PatchCounterKind::Added, counter_positions.added_end_col)
|
||||
{
|
||||
lines.push(line);
|
||||
}
|
||||
lines.push(Line::from(header_spans));
|
||||
if let Some(line) =
|
||||
stats_render.floating_line(PatchCounterKind::Removed, counter_positions.removed_end_col)
|
||||
{
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
if file_count > 1 {
|
||||
for (path, change) in paths {
|
||||
lines.push(Line::from(vec![
|
||||
" └ ".dim(),
|
||||
streaming_patch_path_display(path, change, &self.cwd).into(),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// Returns whether any patch counter still has visible animation work.
|
||||
///
|
||||
/// ChatWidget calls this during pre_draw_tick to decide whether it must
|
||||
/// schedule another frame even without new server input. When animations are
|
||||
/// disabled, the cell is still renderable but never requests timer-driven
|
||||
/// redraws.
|
||||
pub(crate) fn has_active_counter_animation(&self) -> bool {
|
||||
if !self.animations_enabled {
|
||||
return false;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let is_active = |animation: &Option<PatchCounterAnim>| {
|
||||
animation.as_ref().is_some_and(|anim| anim.is_active(now))
|
||||
};
|
||||
is_active(&self.added_anim) || is_active(&self.removed_anim)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct PatchLineStats {
|
||||
added: usize,
|
||||
removed: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CounterPositions {
|
||||
added_end_col: Option<usize>,
|
||||
removed_end_col: Option<usize>,
|
||||
}
|
||||
|
||||
struct PatchStatsRender {
|
||||
added: Option<CounterRender>,
|
||||
removed: Option<CounterRender>,
|
||||
}
|
||||
|
||||
struct CounterRender {
|
||||
text: String,
|
||||
slot_width: usize,
|
||||
floating: Option<FloatingCounterRender>,
|
||||
}
|
||||
|
||||
struct FloatingCounterRender {
|
||||
text: String,
|
||||
style: Style,
|
||||
}
|
||||
|
||||
fn streaming_patch_stats<'a>(changes: impl IntoIterator<Item = &'a FileChange>) -> PatchLineStats {
|
||||
let (added, removed) = changes
|
||||
.into_iter()
|
||||
.fold((0, 0), |(added, removed), change| {
|
||||
let (change_added, change_removed) = file_change_line_counts(change);
|
||||
(added + change_added, removed + change_removed)
|
||||
});
|
||||
PatchLineStats { added, removed }
|
||||
}
|
||||
|
||||
fn file_change_line_counts(change: &FileChange) -> (usize, usize) {
|
||||
match change {
|
||||
FileChange::Add { content } => (content.lines().count(), 0),
|
||||
FileChange::Delete { content } => (0, content.lines().count()),
|
||||
FileChange::Update { unified_diff, .. } => update_patch_line_counts(unified_diff),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct UpdatePatchChunkLines {
|
||||
old: Vec<String>,
|
||||
new: Vec<String>,
|
||||
}
|
||||
|
||||
fn update_patch_line_counts(diff: &str) -> (usize, usize) {
|
||||
let mut chunks = Vec::new();
|
||||
let mut current: Option<UpdatePatchChunkLines> = None;
|
||||
|
||||
for line in diff.lines() {
|
||||
if line.starts_with("@@") {
|
||||
if let Some(chunk) = current.take() {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
current = Some(UpdatePatchChunkLines::default());
|
||||
continue;
|
||||
}
|
||||
|
||||
if line == "*** End of File" || line.starts_with("+++") || line.starts_with("---") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((prefix, text)) = line.split_at_checked(1) else {
|
||||
continue;
|
||||
};
|
||||
let chunk = current.get_or_insert_with(UpdatePatchChunkLines::default);
|
||||
match prefix {
|
||||
"-" => chunk.old.push(text.to_string()),
|
||||
"+" => chunk.new.push(text.to_string()),
|
||||
" " => {
|
||||
chunk.old.push(text.to_string());
|
||||
chunk.new.push(text.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(chunk) = current {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
if chunks.is_empty() {
|
||||
return calculate_add_remove_from_diff(diff);
|
||||
}
|
||||
|
||||
let lines_to_text = |lines: &[String]| {
|
||||
if lines.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{}\n", lines.join("\n"))
|
||||
}
|
||||
};
|
||||
|
||||
chunks
|
||||
.iter()
|
||||
.map(|chunk| {
|
||||
let old_text = lines_to_text(&chunk.old);
|
||||
let new_text = lines_to_text(&chunk.new);
|
||||
let diff = diffy::create_patch(&old_text, &new_text).to_string();
|
||||
calculate_add_remove_from_diff(&diff)
|
||||
})
|
||||
.fold((0, 0), |(added, removed), (chunk_added, chunk_removed)| {
|
||||
(added + chunk_added, removed + chunk_removed)
|
||||
})
|
||||
}
|
||||
|
||||
fn streaming_patch_path_display(path: &Path, change: &FileChange, cwd: &Path) -> String {
|
||||
let path_display = display_path_for(path, cwd);
|
||||
if let FileChange::Update {
|
||||
move_path: Some(move_path),
|
||||
..
|
||||
} = change
|
||||
{
|
||||
format!("{path_display} → {}", display_path_for(move_path, cwd))
|
||||
} else {
|
||||
path_display
|
||||
}
|
||||
}
|
||||
|
||||
fn patch_stats_render(
|
||||
PatchLineStats { added, removed }: PatchLineStats,
|
||||
added_anim: Option<&PatchCounterAnim>,
|
||||
removed_anim: Option<&PatchCounterAnim>,
|
||||
now: Instant,
|
||||
) -> PatchStatsRender {
|
||||
PatchStatsRender {
|
||||
added: counter_render(PatchCounterKind::Added, added, added_anim, now),
|
||||
removed: counter_render(PatchCounterKind::Removed, removed, removed_anim, now),
|
||||
}
|
||||
}
|
||||
|
||||
fn counter_render(
|
||||
kind: PatchCounterKind,
|
||||
actual_value: usize,
|
||||
animation: Option<&PatchCounterAnim>,
|
||||
now: Instant,
|
||||
) -> Option<CounterRender> {
|
||||
let active_animation = animation.filter(|anim| anim.is_active(now));
|
||||
let value = active_animation.map_or(actual_value, |anim| anim.committed(now));
|
||||
let floating = active_animation.and_then(|anim| {
|
||||
let floating_value = anim.floating(now);
|
||||
(floating_value > 0).then(|| FloatingCounterRender {
|
||||
text: counter_text(kind, floating_value),
|
||||
style: anim.floating_style(now),
|
||||
})
|
||||
});
|
||||
|
||||
if value == 0 && floating.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(CounterRender {
|
||||
text: counter_text(kind, value),
|
||||
slot_width: counter_text_width(kind, value.max(actual_value)),
|
||||
floating,
|
||||
})
|
||||
}
|
||||
|
||||
impl PatchStatsRender {
|
||||
fn counter(&self, kind: PatchCounterKind) -> Option<&CounterRender> {
|
||||
match kind {
|
||||
PatchCounterKind::Added => self.added.as_ref(),
|
||||
PatchCounterKind::Removed => self.removed.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_counter_spans(
|
||||
&self,
|
||||
spans: &mut Vec<Span<'static>>,
|
||||
prefix_width: usize,
|
||||
) -> CounterPositions {
|
||||
let mut current_col = prefix_width;
|
||||
let mut positions = CounterPositions {
|
||||
added_end_col: None,
|
||||
removed_end_col: None,
|
||||
};
|
||||
for kind in [PatchCounterKind::Added, PatchCounterKind::Removed] {
|
||||
let Some(counter) = self.counter(kind) else {
|
||||
continue;
|
||||
};
|
||||
spans.push(" ".into());
|
||||
current_col += 1;
|
||||
|
||||
let text_width = UnicodeWidthStr::width(counter.text.as_str());
|
||||
let padding = counter.slot_width.saturating_sub(text_width);
|
||||
spans.push(Span::styled(
|
||||
format!("{}{}", " ".repeat(padding), counter.text),
|
||||
kind.normal_style(),
|
||||
));
|
||||
current_col += counter.slot_width;
|
||||
|
||||
match kind {
|
||||
PatchCounterKind::Added => positions.added_end_col = Some(current_col),
|
||||
PatchCounterKind::Removed => positions.removed_end_col = Some(current_col),
|
||||
}
|
||||
}
|
||||
positions
|
||||
}
|
||||
|
||||
fn floating_line(
|
||||
&self,
|
||||
kind: PatchCounterKind,
|
||||
end_col: Option<usize>,
|
||||
) -> Option<Line<'static>> {
|
||||
let counter = self.counter(kind)?;
|
||||
let floating = counter.floating.as_ref()?;
|
||||
let end_col = end_col?;
|
||||
let width = UnicodeWidthStr::width(floating.text.as_str());
|
||||
let pad = end_col.saturating_sub(width);
|
||||
Some(Line::from(vec![
|
||||
Span::from(" ".repeat(pad)),
|
||||
Span::styled(floating.text.clone(), floating.style),
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
fn counter_text(kind: PatchCounterKind, value: usize) -> String {
|
||||
format!("{}{value}", kind.sigil())
|
||||
}
|
||||
|
||||
fn counter_text_width(kind: PatchCounterKind, value: usize) -> usize {
|
||||
UnicodeWidthStr::width(counter_text(kind, value).as_str())
|
||||
}
|
||||
|
||||
fn span_width(span: &Span<'_>) -> usize {
|
||||
UnicodeWidthStr::width(span.content.as_ref())
|
||||
}
|
||||
|
||||
impl HistoryCell for StreamingPatchHistoryCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
let bullet = activity_indicator(
|
||||
Some(self.start_time),
|
||||
MotionMode::from_animations_enabled(self.animations_enabled),
|
||||
ReducedMotionIndicator::StaticBullet,
|
||||
)
|
||||
.unwrap_or_else(|| "•".dim());
|
||||
|
||||
self.render_lines(bullet, /*include_counter_animation*/ true)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.render_lines("•".into(), /*include_counter_animation*/ false))
|
||||
}
|
||||
|
||||
fn transcript_animation_tick(&self) -> Option<u64> {
|
||||
if !self.has_active_counter_animation() {
|
||||
return None;
|
||||
}
|
||||
Some((self.start_time.elapsed().as_millis() / u128::from(PATCH_PROGRESS_FRAME_MS)) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CompletedMcpToolCallWithImageOutput {
|
||||
_image: DynamicImage,
|
||||
@@ -3143,6 +3579,29 @@ pub(crate) fn new_patch_event(
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the transient live patch preview for a streaming patch update.
|
||||
///
|
||||
/// The returned cell starts with no counter animation because the initial
|
||||
/// snapshot is the visual baseline. Subsequent calls to
|
||||
/// StreamingPatchHistoryCell::update may animate increases relative to this
|
||||
/// baseline until the durable patch item starts.
|
||||
pub(crate) fn new_active_patch_event(
|
||||
call_id: String,
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
cwd: &Path,
|
||||
animations_enabled: bool,
|
||||
) -> StreamingPatchHistoryCell {
|
||||
StreamingPatchHistoryCell {
|
||||
call_id,
|
||||
changes,
|
||||
cwd: cwd.to_path_buf(),
|
||||
start_time: Instant::now(),
|
||||
animations_enabled,
|
||||
added_anim: None,
|
||||
removed_anim: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
@@ -3476,6 +3935,39 @@ mod tests {
|
||||
std::env::temp_dir()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_patch_line_counts_ignore_duplicated_progress_context() {
|
||||
let change = FileChange::Update {
|
||||
unified_diff:
|
||||
"@@\n-context before\n-old\n-context after\n+context before\n+new\n+context after\n"
|
||||
.to_string(),
|
||||
move_path: None,
|
||||
};
|
||||
|
||||
assert_eq!(file_change_line_counts(&change), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_patch_line_counts_ignore_context_for_insertions() {
|
||||
let change = FileChange::Update {
|
||||
unified_diff: "@@\n-context\n+context\n+added\n".to_string(),
|
||||
move_path: None,
|
||||
};
|
||||
|
||||
assert_eq!(file_change_line_counts(&change), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_patch_line_counts_handle_standard_unified_context() {
|
||||
let change = FileChange::Update {
|
||||
unified_diff: "@@ -1,3 +1,3 @@\n context before\n-old\n+new\n context after\n"
|
||||
.to_string(),
|
||||
move_path: None,
|
||||
};
|
||||
|
||||
assert_eq!(file_change_line_counts(&change), (1, 1));
|
||||
}
|
||||
|
||||
fn stdio_server_config(
|
||||
command: &str,
|
||||
args: Vec<&str>,
|
||||
|
||||
@@ -150,6 +150,7 @@ mod npm_registry;
|
||||
pub(crate) mod onboarding;
|
||||
mod oss_selection;
|
||||
mod pager_overlay;
|
||||
mod patch_progress_animation;
|
||||
mod permission_compat;
|
||||
pub(crate) mod public_widgets;
|
||||
mod render;
|
||||
|
||||
364
codex-rs/tui/src/patch_progress_animation.rs
Normal file
364
codex-rs/tui/src/patch_progress_animation.rs
Normal file
@@ -0,0 +1,364 @@
|
||||
//! Animation state for live apply_patch line-count counters.
|
||||
//!
|
||||
//! The TUI receives patch previews as complete snapshots of the current file
|
||||
//! changes, not as line-count deltas. This module converts monotonically
|
||||
//! increasing added/removed totals into a small presentation state machine:
|
||||
//! newly observed lines appear as a floating counter, drain into the committed
|
||||
//! header total, and stop once no floating value remains.
|
||||
//!
|
||||
//! The module deliberately does not own patch parsing, file labels, or
|
||||
//! transcript rendering. Callers compute the current and next totals from the
|
||||
//! active patch cell, pass those totals to update_counter_anim, and schedule
|
||||
//! redraws while PatchCounterAnim::is_active remains true.
|
||||
//!
|
||||
//! A first snapshot establishes the baseline and is not animated; otherwise a
|
||||
//! newly-created patch would show the entire file as a fresh delta. Decreases
|
||||
//! also do not animate, because they usually mean the app-server sent a revised
|
||||
//! snapshot rather than a user-visible removal of already-rendered progress.
|
||||
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
|
||||
/// Millisecond cadence used while a live patch counter animation is active.
|
||||
///
|
||||
/// The same cadence is used both to schedule TUI redraws and to produce the
|
||||
/// transcript animation tick, so a running counter invalidates the active cell
|
||||
/// even when no new app-server notification has arrived.
|
||||
pub(crate) const PATCH_PROGRESS_FRAME_MS: u64 = 50;
|
||||
|
||||
const PULSE_IN_MS: u64 = 180;
|
||||
const STEP_MS: u64 = 60;
|
||||
const DRAIN_CAP_MS: u64 = 800;
|
||||
|
||||
/// Identifies which line-count side a patch progress counter represents.
|
||||
///
|
||||
/// The kind owns only display concerns that are shared by committed and
|
||||
/// floating counters: the sigil and the stable foreground color. It does not
|
||||
/// encode any parsing behavior, so callers must compute added and removed line
|
||||
/// totals before constructing animations.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PatchCounterKind {
|
||||
/// Added-line counter rendered with a green plus sigil.
|
||||
Added,
|
||||
/// Removed-line counter rendered with a red minus sigil.
|
||||
Removed,
|
||||
}
|
||||
|
||||
impl PatchCounterKind {
|
||||
/// Returns the sign rendered before the numeric counter value.
|
||||
pub(crate) fn sigil(self) -> char {
|
||||
match self {
|
||||
PatchCounterKind::Added => '+',
|
||||
PatchCounterKind::Removed => '-',
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable style for the committed counter for this side.
|
||||
///
|
||||
/// Floating counters may temporarily add pulse styling, but the committed
|
||||
/// header value always uses this style so the target column stays visually
|
||||
/// anchored.
|
||||
pub(crate) fn normal_style(self) -> Style {
|
||||
match self {
|
||||
PatchCounterKind::Added => Style::default().green(),
|
||||
PatchCounterKind::Removed => Style::default().red(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks one floating-to-committed patch line-count animation.
|
||||
///
|
||||
/// An instance is owned by a single StreamingPatchHistoryCell and is mutated
|
||||
/// only on the UI thread when newer patch snapshots arrive. base is the
|
||||
/// committed value at the start of the current drain, and target is the total
|
||||
/// value that will be committed once the drain finishes. Calling
|
||||
/// PatchCounterAnim::add_delta while the animation is active preserves the
|
||||
/// current committed value before extending the target, which avoids visible
|
||||
/// jumps when app-server notifications arrive close together.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct PatchCounterAnim {
|
||||
kind: PatchCounterKind,
|
||||
base: usize,
|
||||
target: usize,
|
||||
pulse_in_end: Instant,
|
||||
drain_start: Instant,
|
||||
drain_end: Instant,
|
||||
}
|
||||
|
||||
impl PatchCounterAnim {
|
||||
/// Starts a new counter animation from an already-rendered total.
|
||||
///
|
||||
/// base must be the committed total currently visible in the header, and
|
||||
/// delta must be the newly observed increase for the same counter kind.
|
||||
/// Passing the full next total as delta would double-count the baseline
|
||||
/// and make the floating counter report too many lines.
|
||||
pub(crate) fn start(kind: PatchCounterKind, base: usize, delta: usize, now: Instant) -> Self {
|
||||
let pulse_in_end = now + Duration::from_millis(PULSE_IN_MS);
|
||||
let drain_start = pulse_in_end;
|
||||
let drain_end = drain_start + drain_duration_for(delta);
|
||||
Self {
|
||||
kind,
|
||||
base,
|
||||
target: base.saturating_add(delta),
|
||||
pulse_in_end,
|
||||
drain_start,
|
||||
drain_end,
|
||||
}
|
||||
}
|
||||
|
||||
/// Coalesces an additional increase into a running animation.
|
||||
///
|
||||
/// The committed value at now becomes the new base before the target is
|
||||
/// extended, so the rendered total remains continuous across snapshots.
|
||||
/// Callers should only use this for increases on an active animation; use
|
||||
/// update_counter_anim for normal snapshot reconciliation.
|
||||
pub(crate) fn add_delta(&mut self, delta: usize, now: Instant) {
|
||||
let current = self.committed(now);
|
||||
self.base = current;
|
||||
self.target = self.target.saturating_add(delta);
|
||||
self.drain_start = now.max(self.pulse_in_end);
|
||||
self.drain_end = self.drain_start + drain_duration_for(self.target.saturating_sub(current));
|
||||
}
|
||||
|
||||
/// Returns the portion of the target value that should render in the header.
|
||||
///
|
||||
/// The value is clamped between base and target and is safe to query with
|
||||
/// any Instant. Querying before the drain starts intentionally keeps the
|
||||
/// committed header unchanged while the floating counter pulses in.
|
||||
pub(crate) fn committed(&self, now: Instant) -> usize {
|
||||
if now <= self.drain_start || self.target == self.base {
|
||||
return self.base;
|
||||
}
|
||||
if now >= self.drain_end {
|
||||
return self.target;
|
||||
}
|
||||
|
||||
let total = self
|
||||
.drain_end
|
||||
.saturating_duration_since(self.drain_start)
|
||||
.as_secs_f64()
|
||||
.max(f64::EPSILON);
|
||||
let elapsed = now
|
||||
.saturating_duration_since(self.drain_start)
|
||||
.as_secs_f64();
|
||||
let span = self.target.saturating_sub(self.base) as f64;
|
||||
let value = self.base as f64 + span * (elapsed / total);
|
||||
value.round().clamp(self.base as f64, self.target as f64) as usize
|
||||
}
|
||||
|
||||
/// Returns the portion of the target value that should render as floating.
|
||||
///
|
||||
/// This is always target - committed(now), so callers can render the
|
||||
/// floating value above or below the header without separately tracking how
|
||||
/// much of the delta has already drained.
|
||||
pub(crate) fn floating(&self, now: Instant) -> usize {
|
||||
self.target.saturating_sub(self.committed(now))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn target(&self) -> usize {
|
||||
self.target
|
||||
}
|
||||
|
||||
/// Returns whether the animation still needs redraws.
|
||||
///
|
||||
/// An animation remains active while there is a floating value to render.
|
||||
/// Once this returns false the caller may keep the struct around, but
|
||||
/// scheduling more animation frames for it would waste redraws with no
|
||||
/// visual change.
|
||||
pub(crate) fn is_active(&self, now: Instant) -> bool {
|
||||
self.floating(now) > 0
|
||||
}
|
||||
|
||||
/// Returns the style for the current floating counter phase.
|
||||
///
|
||||
/// Pulse-in is bold and steady drain uses the normal counter style. Callers
|
||||
/// should not use this for committed header values because doing so would
|
||||
/// make the target column itself pulse.
|
||||
pub(crate) fn floating_style(&self, now: Instant) -> Style {
|
||||
if now < self.pulse_in_end {
|
||||
return self.kind.normal_style().add_modifier(Modifier::BOLD);
|
||||
}
|
||||
self.kind.normal_style()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconciles a complete patch snapshot into optional counter animation state.
|
||||
///
|
||||
/// The first non-empty snapshot establishes the baseline, disabled animations
|
||||
/// clear any existing state, and decreases clear the animation because they
|
||||
/// represent snapshot replacement rather than forward progress. This function
|
||||
/// is the preferred entry point for callers that receive complete snapshots; a
|
||||
/// caller that directly starts an animation for the baseline would make the
|
||||
/// first preview look like newly-arrived progress.
|
||||
pub(crate) fn update_counter_anim(
|
||||
animation: &mut Option<PatchCounterAnim>,
|
||||
kind: PatchCounterKind,
|
||||
current_value: usize,
|
||||
next_value: usize,
|
||||
had_existing_changes: bool,
|
||||
animations_enabled: bool,
|
||||
now: Instant,
|
||||
) {
|
||||
if current_value == next_value {
|
||||
return;
|
||||
}
|
||||
|
||||
if !animations_enabled || !had_existing_changes || next_value <= current_value {
|
||||
*animation = None;
|
||||
return;
|
||||
}
|
||||
|
||||
let delta = next_value - current_value;
|
||||
match animation {
|
||||
Some(anim) if anim.is_active(now) => anim.add_delta(delta, now),
|
||||
_ => {
|
||||
*animation = Some(PatchCounterAnim::start(kind, current_value, delta, now));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_duration_for(delta: usize) -> Duration {
|
||||
let ms = STEP_MS
|
||||
.saturating_mul(delta as u64)
|
||||
.clamp(STEP_MS, DRAIN_CAP_MS);
|
||||
Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn t0() -> Instant {
|
||||
Instant::now()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_starts_at_base() {
|
||||
let now = t0();
|
||||
let anim = PatchCounterAnim::start(
|
||||
PatchCounterKind::Added,
|
||||
/*base*/ 20,
|
||||
/*delta*/ 5,
|
||||
now,
|
||||
);
|
||||
|
||||
assert_eq!(anim.committed(now), 20);
|
||||
assert_eq!(anim.floating(now), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_reaches_target_after_drain() {
|
||||
let now = t0();
|
||||
let anim = PatchCounterAnim::start(
|
||||
PatchCounterKind::Added,
|
||||
/*base*/ 20,
|
||||
/*delta*/ 5,
|
||||
now,
|
||||
);
|
||||
let after = now + Duration::from_millis(/*millis*/ PULSE_IN_MS + DRAIN_CAP_MS + 10);
|
||||
|
||||
assert_eq!(anim.committed(after), 25);
|
||||
assert_eq!(anim.floating(after), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesce_keeps_committed_continuous_and_extends_target() {
|
||||
let now = t0();
|
||||
let mut anim = PatchCounterAnim::start(
|
||||
PatchCounterKind::Added,
|
||||
/*base*/ 20,
|
||||
/*delta*/ 5,
|
||||
now,
|
||||
);
|
||||
let mid = now + Duration::from_millis(/*millis*/ PULSE_IN_MS + 150);
|
||||
let before = anim.committed(mid);
|
||||
|
||||
anim.add_delta(/*delta*/ 3, mid);
|
||||
|
||||
assert_eq!(anim.committed(mid), before);
|
||||
assert_eq!(anim.target(), 28);
|
||||
let later = mid + Duration::from_millis(/*millis*/ DRAIN_CAP_MS + 10);
|
||||
assert_eq!(anim.committed(later), 28);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_once_floating_counter_drains() {
|
||||
let now = t0();
|
||||
let anim = PatchCounterAnim::start(
|
||||
PatchCounterKind::Added,
|
||||
/*base*/ 0,
|
||||
/*delta*/ 2,
|
||||
now,
|
||||
);
|
||||
let before_drain = now + Duration::from_millis(/*millis*/ PULSE_IN_MS + STEP_MS - 10);
|
||||
let after_drain =
|
||||
now + Duration::from_millis(/*millis*/ PULSE_IN_MS + STEP_MS * 2 + 10);
|
||||
|
||||
assert!(anim.is_active(before_drain));
|
||||
assert!(!anim.is_active(after_drain));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_counter_anim_skips_baseline_and_decreases() {
|
||||
let now = t0();
|
||||
let mut anim = None;
|
||||
|
||||
update_counter_anim(
|
||||
&mut anim,
|
||||
PatchCounterKind::Added,
|
||||
/*current_value*/ 0,
|
||||
/*next_value*/ 5,
|
||||
/*had_existing_changes*/ false,
|
||||
/*animations_enabled*/ true,
|
||||
now,
|
||||
);
|
||||
assert!(anim.is_none());
|
||||
|
||||
update_counter_anim(
|
||||
&mut anim,
|
||||
PatchCounterKind::Added,
|
||||
/*current_value*/ 5,
|
||||
/*next_value*/ 4,
|
||||
/*had_existing_changes*/ true,
|
||||
/*animations_enabled*/ true,
|
||||
now,
|
||||
);
|
||||
assert!(anim.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_counter_anim_coalesces_active_animation() {
|
||||
let now = t0();
|
||||
let mut anim = None;
|
||||
|
||||
update_counter_anim(
|
||||
&mut anim,
|
||||
PatchCounterKind::Added,
|
||||
/*current_value*/ 5,
|
||||
/*next_value*/ 8,
|
||||
/*had_existing_changes*/ true,
|
||||
/*animations_enabled*/ true,
|
||||
now,
|
||||
);
|
||||
let mid = now + Duration::from_millis(/*millis*/ PULSE_IN_MS + 100);
|
||||
update_counter_anim(
|
||||
&mut anim,
|
||||
PatchCounterKind::Added,
|
||||
/*current_value*/ 8,
|
||||
/*next_value*/ 10,
|
||||
/*had_existing_changes*/ true,
|
||||
/*animations_enabled*/ true,
|
||||
mid,
|
||||
);
|
||||
|
||||
let anim = anim.expect("active animation");
|
||||
assert_eq!(anim.target(), 10);
|
||||
assert!(anim.committed(mid) >= 5);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user