use std::cell::RefCell;
use std::time::{Duration, Instant};
use globset::{Glob, GlobMatcher};
use rand::RngExt;
use std::collections::VecDeque;
use unicode_width::UnicodeWidthStr;
use crate::git::{CommitMetadata, DiffHunk, FileChange, FileStatus, LineChangeType};
use crate::syntax::Highlighter;
#[derive(Debug, Clone)]
pub struct SpeedRule {
pub matcher: GlobMatcher,
pub speed_ms: u64,
}
impl SpeedRule {
pub fn parse(s: &str) -> Option<Self> {
let parts: Vec<&str> = s.rsplitn(2, ':').collect();
if parts.len() != 2 {
return None;
}
let speed_ms = parts[0].parse::<u64>().ok()?;
let pattern_str = parts[1];
let glob = Glob::new(pattern_str).ok()?;
let matcher = glob.compile_matcher();
Some(Self { matcher, speed_ms })
}
pub fn matches(&self, path: &str) -> bool {
self.matcher.is_match(path)
}
}
const CURSOR_MOVE_PAUSE: f64 = 0.5; const CURSOR_MOVE_SHORT_MULTIPLIER: f64 = 1.0; const CURSOR_MOVE_MEDIUM_MULTIPLIER: f64 = 0.3; const CURSOR_MOVE_LONG_MULTIPLIER: f64 = 0.05; const MAX_SCROLL_STEPS: usize = 60; const MIN_LOG_STEPS: usize = 50; const LOG_SCALE_FACTOR: f64 = 8.0; const DELETE_LINE_PAUSE: f64 = 10.0; const INSERT_LINE_PAUSE: f64 = 6.7; const HUNK_PAUSE: f64 = 50.0; const CHECKOUT_PAUSE: f64 = 16.7; const CHECKOUT_OUTPUT_PAUSE: f64 = 33.3; const OPEN_FILE_FIRST_PAUSE: f64 = 33.3; const OPEN_FILE_PAUSE: f64 = 50.0; const OPEN_CMD_PAUSE: f64 = 16.7; const FILE_SWITCH_PAUSE: f64 = 26.7; const GIT_ADD_PAUSE: f64 = 33.3; const GIT_ADD_CMD_PAUSE: f64 = 16.7; const GIT_COMMIT_PAUSE: f64 = 26.7; const COMMIT_OUTPUT_PAUSE: f64 = 33.3; const GIT_PUSH_PAUSE: f64 = 16.7; const PUSH_OUTPUT_PAUSE: f64 = 10.0; const PUSH_FINAL_PAUSE: f64 = 66.7;
const MAX_LINE_CHECKPOINTS: usize = 200;
const MAX_CHANGE_CHECKPOINTS: usize = 64;
#[derive(Debug, Clone)]
pub struct EditorBuffer {
pub lines: Vec<String>,
pub cursor_line: usize,
pub cursor_col: usize,
pub scroll_offset: usize,
pub cached_highlights: Vec<crate::syntax::HighlightSpan>,
pub old_highlights: Vec<crate::syntax::HighlightSpan>,
pub new_highlights: Vec<crate::syntax::HighlightSpan>,
pub old_content_lines: Vec<String>,
pub new_content_lines: Vec<String>,
pub old_content_line_offsets: Vec<usize>,
pub new_content_line_offsets: Vec<usize>,
}
impl EditorBuffer {
pub fn new() -> Self {
Self {
lines: vec![String::new()],
cursor_line: 0,
cursor_col: 0,
scroll_offset: 0,
cached_highlights: Vec::new(),
old_highlights: Vec::new(),
new_highlights: Vec::new(),
old_content_lines: Vec::new(),
new_content_lines: Vec::new(),
old_content_line_offsets: Vec::new(),
new_content_line_offsets: Vec::new(),
}
}
pub fn from_content(content: &str) -> Self {
let lines: Vec<String> = if content.is_empty() {
vec![String::new()]
} else {
content.lines().map(|s| s.to_string()).collect()
};
Self {
lines,
cursor_line: 0,
cursor_col: 0,
scroll_offset: 0,
cached_highlights: Vec::new(),
old_highlights: Vec::new(),
new_highlights: Vec::new(),
old_content_lines: Vec::new(),
new_content_lines: Vec::new(),
old_content_line_offsets: Vec::new(),
new_content_line_offsets: Vec::new(),
}
}
pub fn insert_char(&mut self, line: usize, col: usize, ch: char) {
if line >= self.lines.len() {
self.lines.resize(line + 1, String::new());
}
let line_str = &mut self.lines[line];
let byte_idx = line_str
.char_indices()
.nth(col)
.map(|(idx, _)| idx)
.unwrap_or_else(|| line_str.len());
line_str.insert(byte_idx, ch);
}
pub fn insert_line(&mut self, line: usize, content: String) {
if line > self.lines.len() {
self.lines.resize(line, String::new());
}
self.lines.insert(line, content);
}
pub fn delete_line(&mut self, line: usize) {
if line < self.lines.len() {
self.lines.remove(line);
}
if self.lines.is_empty() {
self.lines.push(String::new());
}
}
}
#[derive(Debug, Clone)]
pub enum AnimationStep {
InsertChar {
line: usize,
col: usize,
ch: char,
},
InsertLine {
line: usize,
content: String,
},
DeleteLine {
line: usize,
},
MoveCursor {
line: usize,
col: usize,
},
Pause {
multiplier: f64,
},
SwitchFile {
file_index: usize,
old_content: String,
new_content: String,
path: String,
},
OpenFileDialogStart,
DialogTypeChar {
ch: char,
},
TerminalPrompt,
TerminalTypeChar {
ch: char,
},
TerminalOutput {
text: String,
},
ResetState,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AnimationState {
Idle,
Playing,
Finished,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ActivePane {
Editor,
Terminal,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum StepMode {
Line,
Change,
}
#[derive(Clone)]
struct ManualCheckpoint {
step_index: usize,
buffer: EditorBuffer,
current_file_index: usize,
current_file_path: Option<String>,
terminal_lines: Vec<String>,
active_pane: ActivePane,
line_offset: isize,
dialog_title: Option<String>,
dialog_typing_text: String,
speed_ms: u64,
}
impl ManualCheckpoint {
fn new(engine: &AnimationEngine) -> Self {
let resume_step = engine
.current_step
.saturating_add(1)
.min(engine.steps.len());
Self {
step_index: resume_step,
buffer: engine.buffer.clone(),
current_file_index: engine.current_file_index,
current_file_path: engine.current_file_path.clone(),
terminal_lines: engine.terminal_lines.clone(),
active_pane: engine.active_pane.clone(),
line_offset: engine.line_offset,
dialog_title: engine.dialog_title.clone(),
dialog_typing_text: engine.dialog_typing_text.clone(),
speed_ms: engine.speed_ms,
}
}
}
#[derive(Clone, Copy, PartialEq)]
enum CheckpointKind {
Line,
Change,
}
pub struct AnimationEngine {
pub buffer: EditorBuffer,
pub state: AnimationState,
steps: Vec<AnimationStep>,
current_step: usize,
last_update: Instant,
speed_ms: u64,
base_speed_ms: u64,
next_step_delay: u64,
pause_until: Option<Instant>,
pub cursor_visible: bool,
cursor_blink_timer: Instant,
viewport_height: usize,
content_width: usize,
pub current_file_index: usize,
pub current_file_path: Option<String>,
pub terminal_lines: Vec<String>,
pub active_pane: ActivePane,
pub highlighter: RefCell<Highlighter>,
pub line_offset: isize,
#[allow(dead_code)]
target_fps: u64,
frame_interval_ms: u64,
last_frame: Instant,
pub dialog_title: Option<String>,
pub dialog_typing_text: String,
current_metadata: Option<CommitMetadata>,
pending_metadata: Option<CommitMetadata>,
speed_rules: Vec<SpeedRule>,
paused: bool,
line_checkpoints: VecDeque<ManualCheckpoint>,
change_checkpoints: VecDeque<ManualCheckpoint>,
}
impl AnimationEngine {
pub fn new(speed_ms: u64) -> Self {
let target_fps: u64 = 120;
let frame_interval_ms = 1000 / target_fps;
let now = Instant::now();
Self {
buffer: EditorBuffer::new(),
state: AnimationState::Idle,
steps: Vec::new(),
current_step: 0,
last_update: now,
speed_ms,
base_speed_ms: speed_ms,
next_step_delay: speed_ms,
pause_until: None,
cursor_visible: true,
cursor_blink_timer: now,
viewport_height: 20, content_width: 80, current_file_index: 0,
current_file_path: None,
terminal_lines: Vec::new(),
active_pane: ActivePane::Terminal, highlighter: RefCell::new(Highlighter::new()),
line_offset: 0,
target_fps,
frame_interval_ms,
last_frame: now,
dialog_title: None,
dialog_typing_text: String::new(),
current_metadata: None,
pending_metadata: None,
speed_rules: Vec::new(),
paused: false,
line_checkpoints: VecDeque::new(),
change_checkpoints: VecDeque::new(),
}
}
pub fn pause(&mut self) {
self.paused = true;
}
pub fn resume(&mut self) {
if self.paused {
self.paused = false;
let now = Instant::now();
self.last_update = now;
self.last_frame = now;
}
}
pub fn manual_step(&mut self, mode: StepMode) -> bool {
if self.state != AnimationState::Playing {
return false;
}
if self.current_step >= self.steps.len() {
self.state = AnimationState::Finished;
return false;
}
self.pause_until = None;
let mut executed = false;
while self.current_step < self.steps.len() {
let step = self.steps[self.current_step].clone();
self.execute_step(step.clone());
self.current_step += 1;
executed = true;
if self.current_step >= self.steps.len() {
self.state = AnimationState::Finished;
}
if Self::is_boundary_step(&step, mode) {
break;
}
}
if executed {
let now = Instant::now();
self.last_update = now;
self.last_frame = now;
}
executed
}
pub fn restore_line_checkpoint(&mut self) -> bool {
if self.line_checkpoints.len() < 2 {
return false;
}
self.line_checkpoints.pop_back();
if let Some(snapshot) = self.line_checkpoints.back().cloned() {
self.apply_checkpoint(snapshot);
true
} else {
false
}
}
pub fn restore_change_checkpoint(&mut self) -> bool {
if self.change_checkpoints.len() < 2 {
return false;
}
self.change_checkpoints.pop_back();
if let Some(snapshot) = self.change_checkpoints.back().cloned() {
self.apply_checkpoint(snapshot);
true
} else {
false
}
}
fn apply_checkpoint(&mut self, snapshot: ManualCheckpoint) {
self.current_step = snapshot.step_index;
self.buffer = snapshot.buffer;
self.current_file_index = snapshot.current_file_index;
self.current_file_path = snapshot.current_file_path;
self.terminal_lines = snapshot.terminal_lines;
self.active_pane = snapshot.active_pane;
self.line_offset = snapshot.line_offset;
self.dialog_title = snapshot.dialog_title;
self.dialog_typing_text = snapshot.dialog_typing_text;
self.speed_ms = snapshot.speed_ms;
self.pause_until = None;
self.paused = true;
self.state = AnimationState::Playing;
}
fn is_boundary_step(step: &AnimationStep, mode: StepMode) -> bool {
match mode {
StepMode::Line => matches!(
step,
AnimationStep::Pause { .. }
| AnimationStep::SwitchFile { .. }
| AnimationStep::TerminalPrompt
| AnimationStep::TerminalOutput { .. }
| AnimationStep::ResetState
),
StepMode::Change => match step {
AnimationStep::SwitchFile { .. }
| AnimationStep::TerminalPrompt
| AnimationStep::TerminalOutput { .. }
| AnimationStep::ResetState => true,
AnimationStep::Pause { multiplier } => Self::is_change_pause(*multiplier),
_ => false,
},
}
}
fn handle_step_checkpoint(&mut self, step: &AnimationStep) {
match step {
AnimationStep::ResetState => {
self.clear_checkpoints();
self.record_checkpoint(CheckpointKind::Change);
self.record_checkpoint(CheckpointKind::Line);
}
AnimationStep::SwitchFile { .. } => {
self.line_checkpoints.clear();
self.record_checkpoint(CheckpointKind::Change);
self.record_checkpoint(CheckpointKind::Line);
}
AnimationStep::Pause { multiplier } if self.active_pane == ActivePane::Editor => {
self.record_checkpoint(CheckpointKind::Line);
if Self::is_change_pause(*multiplier) {
self.record_checkpoint(CheckpointKind::Change);
}
}
_ => {}
}
}
fn is_change_pause(multiplier: f64) -> bool {
(multiplier - HUNK_PAUSE).abs() < f64::EPSILON
}
fn record_checkpoint(&mut self, kind: CheckpointKind) {
if self.current_step == 0 {
return;
}
let snapshot = ManualCheckpoint::new(self);
match kind {
CheckpointKind::Line => {
if self
.line_checkpoints
.back()
.map(|c| c.step_index == snapshot.step_index)
.unwrap_or(false)
{
return;
}
self.line_checkpoints.push_back(snapshot);
if self.line_checkpoints.len() > MAX_LINE_CHECKPOINTS {
self.line_checkpoints.pop_front();
}
}
CheckpointKind::Change => {
if self
.change_checkpoints
.back()
.map(|c| c.step_index == snapshot.step_index)
.unwrap_or(false)
{
return;
}
self.change_checkpoints.push_back(snapshot);
if self.change_checkpoints.len() > MAX_CHANGE_CHECKPOINTS {
self.change_checkpoints.pop_front();
}
}
}
}
fn clear_checkpoints(&mut self) {
self.line_checkpoints.clear();
self.change_checkpoints.clear();
}
pub fn set_speed_rules(&mut self, rules: Vec<SpeedRule>) {
self.speed_rules = rules;
}
fn get_speed_for_file(&self, path: &str) -> u64 {
for rule in &self.speed_rules {
if rule.matches(path) {
return rule.speed_ms;
}
}
self.base_speed_ms
}
pub fn set_viewport_height(&mut self, height: usize) {
self.viewport_height = height;
}
pub fn set_content_width(&mut self, width: usize) {
self.content_width = width;
}
pub fn current_metadata(&self) -> Option<&CommitMetadata> {
self.current_metadata.as_ref()
}
fn calculate_line_offsets(content: &str) -> Vec<usize> {
std::iter::once(0)
.chain(content.bytes().enumerate().filter_map(|(i, b)| {
if b == b'\n' {
Some(i + 1)
} else {
None
}
}))
.collect()
}
fn add_terminal_command(&mut self, command: &str) {
self.steps.push(AnimationStep::TerminalPrompt);
for ch in command.chars() {
self.steps.push(AnimationStep::TerminalTypeChar { ch });
}
}
pub fn load_commit(&mut self, metadata: &CommitMetadata) {
self.pending_metadata = Some(metadata.clone());
self.steps.clear();
self.current_step = 0;
self.state = AnimationState::Playing;
self.last_update = Instant::now();
self.pause_until = None;
let is_working_tree = metadata.hash == "working-tree";
if is_working_tree {
self.add_terminal_command("git diff --stat");
self.steps.push(AnimationStep::Pause {
multiplier: CHECKOUT_PAUSE,
});
self.steps.push(AnimationStep::TerminalOutput {
text: format!("📝 {}", metadata.message),
});
self.steps.push(AnimationStep::TerminalOutput {
text: format!(
"📁 {} file{} changed",
metadata.changes.len(),
if metadata.changes.len() == 1 { "" } else { "s" }
),
});
self.steps.push(AnimationStep::Pause {
multiplier: CHECKOUT_OUTPUT_PAUSE,
});
} else {
let datetime_str = metadata.date.format("%Y-%m-%d %H:%M:%S").to_string();
self.add_terminal_command(&format!("time-travel {}", datetime_str));
self.steps.push(AnimationStep::Pause {
multiplier: CHECKOUT_PAUSE,
});
self.steps.push(AnimationStep::TerminalOutput {
text: "⚡ Initializing temporal displacement field...".to_string(),
});
self.steps.push(AnimationStep::Pause {
multiplier: CHECKOUT_OUTPUT_PAUSE * 0.5,
});
self.steps.push(AnimationStep::TerminalOutput {
text: "✨ Warping through spacetime...".to_string(),
});
self.steps.push(AnimationStep::Pause {
multiplier: CHECKOUT_OUTPUT_PAUSE * 0.5,
});
self.steps.push(AnimationStep::TerminalOutput {
text: format!("🕰️ Arrived at {}", datetime_str),
});
self.steps.push(AnimationStep::TerminalOutput {
text: format!(
"📍 Location: commit {} by {}",
&metadata.hash[..7],
metadata.author
),
});
self.steps.push(AnimationStep::Pause {
multiplier: CHECKOUT_OUTPUT_PAUSE,
});
}
self.steps.push(AnimationStep::ResetState);
let sorted_indices = metadata.sorted_file_indices();
for &index in &sorted_indices {
let change = &metadata.changes[index];
match (change.is_excluded, &change.status) {
(true, _) => {
let old_content = change.old_content.clone().unwrap_or_default();
let new_content = change.new_content.clone().unwrap_or_default();
self.steps.push(AnimationStep::SwitchFile {
file_index: index,
old_content,
new_content,
path: change.path.clone(),
});
self.steps.push(AnimationStep::Pause {
multiplier: OPEN_FILE_PAUSE,
});
let reason = change
.exclusion_reason
.as_deref()
.unwrap_or("excluded file");
self.steps.push(AnimationStep::TerminalOutput {
text: format!("📦 {} (skipped - {})", change.path, reason),
});
self.steps.push(AnimationStep::Pause {
multiplier: OPEN_CMD_PAUSE,
});
}
(false, FileStatus::Deleted) => {
let old_content = change.old_content.clone().unwrap_or_default();
self.steps.push(AnimationStep::SwitchFile {
file_index: index,
old_content,
new_content: String::new(),
path: change.path.clone(),
});
self.steps.push(AnimationStep::Pause {
multiplier: GIT_ADD_PAUSE,
});
self.add_terminal_command(&format!("rm {}", change.path));
self.steps.push(AnimationStep::Pause {
multiplier: GIT_ADD_CMD_PAUSE,
});
self.add_terminal_command(&format!("git add {}", change.path));
self.steps.push(AnimationStep::Pause {
multiplier: GIT_ADD_CMD_PAUSE,
});
}
(false, FileStatus::Renamed) => {
let old_content = change.old_content.clone().unwrap_or_default();
let new_content = change.new_content.clone().unwrap_or_default();
self.steps.push(AnimationStep::SwitchFile {
file_index: index,
old_content,
new_content,
path: change.path.clone(),
});
self.steps.push(AnimationStep::Pause {
multiplier: GIT_ADD_PAUSE,
});
if let Some(old_path) = &change.old_path {
self.add_terminal_command(&format!("mv {} {}", old_path, change.path));
self.steps.push(AnimationStep::Pause {
multiplier: GIT_ADD_CMD_PAUSE,
});
}
self.add_terminal_command(&format!("git add {}", change.path));
self.steps.push(AnimationStep::Pause {
multiplier: GIT_ADD_CMD_PAUSE,
});
}
(false, _) => {
if index == 0 {
self.steps.push(AnimationStep::Pause {
multiplier: OPEN_FILE_FIRST_PAUSE,
});
} else {
self.steps.push(AnimationStep::Pause {
multiplier: OPEN_FILE_PAUSE,
});
}
self.steps.push(AnimationStep::OpenFileDialogStart);
self.steps.push(AnimationStep::Pause { multiplier: 5.0 });
for ch in change.path.chars() {
self.steps.push(AnimationStep::DialogTypeChar { ch });
}
self.steps.push(AnimationStep::Pause {
multiplier: OPEN_CMD_PAUSE,
});
let old_content = change.old_content.clone().unwrap_or_default();
let new_content = change.new_content.clone().unwrap_or_default();
self.steps.push(AnimationStep::SwitchFile {
file_index: index,
old_content,
new_content,
path: change.path.clone(),
});
self.steps.push(AnimationStep::Pause {
multiplier: FILE_SWITCH_PAUSE,
});
self.generate_steps_for_file(change);
self.steps.push(AnimationStep::Pause {
multiplier: GIT_ADD_PAUSE,
});
self.add_terminal_command(&format!("git add {}", change.path));
self.steps.push(AnimationStep::Pause {
multiplier: GIT_ADD_CMD_PAUSE,
});
}
}
}
if is_working_tree {
self.steps.push(AnimationStep::Pause {
multiplier: PUSH_FINAL_PAUSE,
});
} else {
let parent_hash = format!("{}^", &metadata.hash[..7]);
let commit_message = metadata.message.lines().next().unwrap_or("Update");
self.add_terminal_command(&format!("git commit -m \"{}\"", commit_message));
self.steps.push(AnimationStep::Pause {
multiplier: GIT_COMMIT_PAUSE,
});
self.steps.push(AnimationStep::TerminalOutput {
text: format!("💾 [main {}] {}", &metadata.hash[..7], commit_message),
});
self.steps.push(AnimationStep::TerminalOutput {
text: format!(
"📝 {} file{} changed - immortalized forever!",
metadata.changes.len(),
if metadata.changes.len() == 1 { "" } else { "s" }
),
});
self.steps.push(AnimationStep::Pause {
multiplier: COMMIT_OUTPUT_PAUSE,
});
self.add_terminal_command("git push origin main");
self.steps.push(AnimationStep::Pause {
multiplier: GIT_PUSH_PAUSE,
});
self.steps.push(AnimationStep::TerminalOutput {
text: "🚀 Launching code into the cloud...".to_string(),
});
self.steps.push(AnimationStep::Pause {
multiplier: PUSH_OUTPUT_PAUSE,
});
self.steps.push(AnimationStep::TerminalOutput {
text: "📦 Compressing digital dreams: 100% (5/5)".to_string(),
});
self.steps.push(AnimationStep::Pause {
multiplier: PUSH_OUTPUT_PAUSE,
});
self.steps.push(AnimationStep::TerminalOutput {
text: "✍️ Signing with invisible ink: done.".to_string(),
});
self.steps.push(AnimationStep::Pause {
multiplier: GIT_PUSH_PAUSE,
});
self.steps.push(AnimationStep::TerminalOutput {
text: "📡 Beaming to origin/main via satellite...".to_string(),
});
self.steps.push(AnimationStep::Pause {
multiplier: PUSH_OUTPUT_PAUSE,
});
self.steps.push(AnimationStep::TerminalOutput {
text: format!(
" {}..{} ✨ SUCCESS",
&parent_hash[..7],
&metadata.hash[..7]
),
});
self.steps.push(AnimationStep::Pause {
multiplier: PUSH_FINAL_PAUSE,
});
}
self.buffer = EditorBuffer::new();
self.clear_checkpoints();
}
fn generate_steps_for_file(&mut self, change: &FileChange) {
let mut current_cursor_line = 0;
let mut line_offset = 0i64;
let old_lines: Vec<&str> = change
.old_content
.as_ref()
.map(|c| c.lines().collect())
.unwrap_or_default();
for hunk in &change.hunks {
let target_line = ((hunk.old_start as i64) - 1 + line_offset).max(0) as usize;
let distance = target_line.abs_diff(current_cursor_line);
current_cursor_line = self.generate_cursor_movement(
current_cursor_line,
target_line,
distance,
&old_lines,
);
let (final_cursor_line, _final_buffer_line) =
self.generate_steps_for_hunk(hunk, current_cursor_line, target_line);
current_cursor_line = final_cursor_line;
let additions = hunk
.lines
.iter()
.filter(|l| matches!(l.change_type, LineChangeType::Addition))
.count() as i64;
let deletions = hunk
.lines
.iter()
.filter(|l| matches!(l.change_type, LineChangeType::Deletion))
.count() as i64;
line_offset += additions - deletions;
self.steps.push(AnimationStep::Pause {
multiplier: HUNK_PAUSE,
});
}
}
fn generate_cursor_movement(
&mut self,
from_line: usize,
to_line: usize,
distance: usize,
lines: &[&str],
) -> usize {
if from_line == to_line {
return to_line;
}
let base_speed_multiplier = if distance <= 50 {
CURSOR_MOVE_SHORT_MULTIPLIER
} else if distance <= 200 {
CURSOR_MOVE_MEDIUM_MULTIPLIER
} else {
CURSOR_MOVE_LONG_MULTIPLIER
};
let num_steps = if distance <= MIN_LOG_STEPS {
distance } else {
let log_steps = (distance as f64).ln() * LOG_SCALE_FACTOR;
(log_steps as usize).clamp(MIN_LOG_STEPS, MAX_SCROLL_STEPS)
};
let mut positions = Vec::with_capacity(num_steps + 1);
for i in 0..=num_steps {
let t = i as f64 / num_steps as f64;
let eased = self.ease_in_out_cubic(t);
let line_progress = (eased * distance as f64).round() as usize;
let actual_line = if from_line < to_line {
from_line + line_progress
} else {
from_line - line_progress
};
if positions.is_empty() || positions.last() != Some(&actual_line) {
positions.push(actual_line);
}
}
let pause_multiplier = (CURSOR_MOVE_PAUSE * base_speed_multiplier).max(0.01);
for line in positions {
if line != from_line {
let col = lines
.get(line)
.map(|l| l.chars().take_while(|c| c.is_whitespace()).count())
.unwrap_or(0);
self.steps.push(AnimationStep::MoveCursor { line, col });
self.steps.push(AnimationStep::Pause {
multiplier: pause_multiplier,
});
}
}
to_line
}
fn ease_in_out_cubic(&self, t: f64) -> f64 {
if t < 0.5 {
4.0 * t * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
}
}
fn generate_steps_for_hunk(
&mut self,
hunk: &DiffHunk,
start_cursor_line: usize,
start_buffer_line: usize,
) -> (usize, usize) {
let mut buffer_line = start_buffer_line;
let mut cursor_line = start_cursor_line;
for line_change in &hunk.lines {
match line_change.change_type {
LineChangeType::Deletion => {
self.steps
.push(AnimationStep::DeleteLine { line: buffer_line });
self.steps.push(AnimationStep::Pause {
multiplier: DELETE_LINE_PAUSE,
});
cursor_line = buffer_line;
}
LineChangeType::Addition => {
let content = &line_change.content;
let indentation_len = content.chars().take_while(|c| c.is_whitespace()).count();
let indentation: String = content.chars().take(indentation_len).collect();
self.steps.push(AnimationStep::InsertLine {
line: buffer_line,
content: indentation,
});
for (i, ch) in content.chars().skip(indentation_len).enumerate() {
self.steps.push(AnimationStep::InsertChar {
line: buffer_line,
col: indentation_len + i,
ch,
});
}
cursor_line = buffer_line;
buffer_line += 1;
self.steps.push(AnimationStep::Pause {
multiplier: INSERT_LINE_PAUSE,
});
}
LineChangeType::Context => {
if buffer_line != cursor_line {
let col = line_change
.content
.chars()
.take_while(|c| c.is_whitespace())
.count();
self.steps.push(AnimationStep::MoveCursor {
line: buffer_line,
col,
});
self.steps.push(AnimationStep::Pause {
multiplier: CURSOR_MOVE_PAUSE,
});
}
cursor_line = buffer_line;
buffer_line += 1; }
}
}
(cursor_line, buffer_line)
}
pub fn tick(&mut self) -> bool {
self.update_cursor_blink();
if self.paused {
return true;
}
if self.is_paused() {
return true;
}
if self.state != AnimationState::Playing {
return false;
}
let now = Instant::now();
if !self.should_render_frame(now) {
return false;
}
let executed = self.execute_batch_steps(now);
if self.current_step >= self.steps.len() {
self.state = AnimationState::Finished;
}
executed
}
fn update_cursor_blink(&mut self) {
if self.cursor_blink_timer.elapsed() >= Duration::from_millis(500) {
self.cursor_visible = !self.cursor_visible;
self.cursor_blink_timer = Instant::now();
}
}
fn is_paused(&mut self) -> bool {
if let Some(pause_until) = self.pause_until {
if Instant::now() < pause_until {
return true;
}
self.pause_until = None;
}
false
}
fn should_render_frame(&self, now: Instant) -> bool {
now.duration_since(self.last_frame) >= Duration::from_millis(self.frame_interval_ms)
}
fn execute_batch_steps(&mut self, frame_start: Instant) -> bool {
let mut accumulated_delay = 0u64;
let mut executed_any = false;
while self.current_step < self.steps.len() {
if !self.can_execute_step(executed_any, accumulated_delay) {
break;
}
let step_delay = self.next_step_delay;
let step = self.steps[self.current_step].clone();
self.execute_step(step);
self.current_step += 1;
executed_any = true;
accumulated_delay += step_delay;
}
if executed_any {
self.last_update = Instant::now();
self.last_frame = frame_start;
}
executed_any
}
fn can_execute_step(&self, executed_any: bool, accumulated_delay: u64) -> bool {
if !executed_any {
return self.last_update.elapsed() >= Duration::from_millis(self.next_step_delay);
}
accumulated_delay + self.next_step_delay <= self.frame_interval_ms
}
fn execute_step(&mut self, step: AnimationStep) {
let step_clone = step.clone();
let mut rng = rand::rng();
self.next_step_delay = match &step {
AnimationStep::InsertChar { .. } | AnimationStep::TerminalTypeChar { .. } => {
let variation = rng.random_range(0.7..=1.3);
((self.speed_ms as f64) * variation) as u64
}
AnimationStep::DialogTypeChar { .. } => {
let variation = rng.random_range(0.7..=1.3);
((self.speed_ms as f64) * 2.0 * variation) as u64
}
AnimationStep::Pause { .. } => {
0
}
_ => {
self.speed_ms
}
};
match step {
AnimationStep::InsertChar { line, col, ch } => {
self.active_pane = ActivePane::Editor;
self.buffer.insert_char(line, col, ch);
self.buffer.cursor_line = line;
self.buffer.cursor_col = col + 1;
}
AnimationStep::InsertLine { line, content } => {
self.active_pane = ActivePane::Editor;
let content_len = content.chars().count();
self.buffer.insert_line(line, content);
self.buffer.cursor_line = line;
self.buffer.cursor_col = content_len;
self.line_offset += 1;
}
AnimationStep::DeleteLine { line } => {
self.active_pane = ActivePane::Editor;
self.buffer.delete_line(line);
self.buffer.cursor_line = line;
self.buffer.cursor_col = self
.buffer
.lines
.get(line)
.map(|l| l.chars().take_while(|c| c.is_whitespace()).count())
.unwrap_or(0);
self.line_offset -= 1;
}
AnimationStep::MoveCursor { line, col } => {
self.active_pane = ActivePane::Editor;
self.buffer.cursor_line = line;
self.buffer.cursor_col = col;
}
AnimationStep::Pause { multiplier } => {
let duration_ms = (self.speed_ms as f64 * multiplier) as u64;
self.pause_until = Some(Instant::now() + Duration::from_millis(duration_ms));
}
AnimationStep::OpenFileDialogStart => {
self.dialog_typing_text = String::new();
self.dialog_title = Some("Open File...".to_string());
}
AnimationStep::DialogTypeChar { ch } => {
self.dialog_typing_text.push(ch);
}
AnimationStep::SwitchFile {
file_index,
old_content,
new_content,
path,
} => {
self.active_pane = ActivePane::Editor;
self.dialog_title = None;
self.dialog_typing_text = String::new();
self.current_file_index = file_index;
self.current_file_path = Some(path.clone());
self.buffer = EditorBuffer::from_content(&old_content);
self.speed_ms = self.get_speed_for_file(&path);
self.highlighter.borrow_mut().set_language_from_path(&path);
self.buffer.old_highlights = self.highlighter.borrow_mut().highlight(&old_content);
self.buffer.new_highlights = self.highlighter.borrow_mut().highlight(&new_content);
self.buffer.old_content_lines = if old_content.is_empty() {
vec![String::new()]
} else {
old_content.lines().map(|s| s.to_string()).collect()
};
self.buffer.new_content_lines = if new_content.is_empty() {
vec![String::new()]
} else {
new_content.lines().map(|s| s.to_string()).collect()
};
self.buffer.old_content_line_offsets = Self::calculate_line_offsets(&old_content);
self.buffer.new_content_line_offsets = Self::calculate_line_offsets(&new_content);
self.buffer.cached_highlights = self.buffer.old_highlights.clone();
self.line_offset = 0;
}
AnimationStep::TerminalPrompt => {
self.active_pane = ActivePane::Terminal;
self.terminal_lines.push("~ ".to_string());
}
AnimationStep::TerminalTypeChar { ch } => {
self.active_pane = ActivePane::Terminal;
if let Some(last_line) = self.terminal_lines.last_mut() {
last_line.push(ch);
}
}
AnimationStep::TerminalOutput { text } => {
self.active_pane = ActivePane::Terminal;
self.terminal_lines.push(text);
}
AnimationStep::ResetState => {
if let Some(metadata) = self.pending_metadata.take() {
self.current_metadata = Some(metadata);
}
self.current_file_index = 0;
self.buffer = EditorBuffer::new();
self.current_file_path = None;
self.active_pane = ActivePane::Terminal;
}
}
self.handle_step_checkpoint(&step_clone);
self.update_scroll();
}
fn calculate_line_display_height(&self, line: &str) -> usize {
if self.content_width == 0 {
return 1;
}
let line_num_width = format!("{}", self.buffer.lines.len()).len().max(3);
let left_padding = 2;
let line_num_and_space = line_num_width + 1;
let separator = 2;
let right_padding = 2;
let fixed_width = left_padding + line_num_and_space + separator + right_padding;
let text_width = self.content_width.saturating_sub(fixed_width);
if text_width == 0 {
return 1;
}
let display_width = line.width();
display_width.div_ceil(text_width).max(1)
}
fn update_scroll(&mut self) {
if self.viewport_height == 0 {
return;
}
let cursor_line = self.buffer.cursor_line;
let mut display_line_positions = Vec::with_capacity(self.buffer.lines.len());
let mut current_display_line = 0;
for line in &self.buffer.lines {
display_line_positions.push(current_display_line);
current_display_line += self.calculate_line_display_height(line);
}
let total_display_lines = current_display_line;
let cursor_display_line = display_line_positions
.get(cursor_line)
.copied()
.unwrap_or(0);
let half_viewport = self.viewport_height / 2;
let target_display_offset = if cursor_display_line < half_viewport {
0
} else if cursor_display_line + half_viewport >= total_display_lines {
total_display_lines.saturating_sub(self.viewport_height)
} else {
cursor_display_line.saturating_sub(half_viewport)
};
let mut logical_offset = 0;
for (line_idx, &display_pos) in display_line_positions.iter().enumerate() {
if display_pos >= target_display_offset {
logical_offset = line_idx;
break;
}
}
self.buffer.scroll_offset = logical_offset;
}
pub fn is_finished(&self) -> bool {
self.state == AnimationState::Finished
}
}