Skip to content

Commit 2506b99

Browse files
ddaniel27unhappychoice
authored andcommitted
added playback controlls
1 parent 4c951d8 commit 2506b99

8 files changed

Lines changed: 591 additions & 74 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ gitlogue --commit abc123 --loop
107107
# Loop through a commit range
108108
gitlogue --commit HEAD~10..HEAD --loop
109109

110+
# Enable manual playback controls (Ctrl+j/Ctrl+l commit prev/next, j/l line prev/next, J/L change prev/next, k=play/pause)
111+
gitlogue --playback-controls --order asc
112+
110113
# View staged changes (default)
111114
gitlogue diff
112115

docs/configuration.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ ignore_patterns = []
5252
# Speed rules for different file types (pattern:milliseconds)
5353
# Examples: ["*.java:50", "*.xml:5", "*.rs:30"]
5454
speed_rules = []
55+
56+
# Enable manual playback controls (Ctrl+j/Ctrl+l for commits, j/l for line prev/next, J/L for change prev/next)
57+
playback_controls = false
5558
```
5659

5760
## Configuration Options
@@ -185,6 +188,16 @@ speed_rules = [
185188

186189
Note: CLI `--speed-rule` flags take priority over config file rules. Rules are evaluated in order (CLI first, then config).
187190

191+
### `playback_controls`
192+
193+
Enable keyboard playback controls without needing to pass `--playback-controls` each time.
194+
195+
- **Type**: Boolean
196+
- **Default**: `false`
197+
- **Example**: `playback_controls = true`
198+
199+
When set to `true`, gitlogue starts with manual controls enabled so you can use `Ctrl+j`/`Ctrl+l` for commit navigation, `j`/`l` for stepping to the previous/next line, and `J`/`L` for stepping to the previous/next change.
200+
188201
## Configuration Priority
189202

190203
Settings are applied in the following order (highest priority first):

docs/usage.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,25 @@ This is especially useful when viewing a specific commit or commit range and you
217217
- Desktop ricing and ambience
218218
- Educational replays of feature development
219219

220+
### `--playback-controls[=BOOL]`
221+
222+
Enable manual playback controls. When active:
223+
224+
- `Ctrl+j` jumps to the previous commit in history
225+
- `Ctrl+l` advances to the next commit
226+
- `j` steps to the previous line chunk
227+
- `l` steps to the next line chunk
228+
- `J` jumps to the previous change/file
229+
- `L` jumps to the next change/file
230+
- `k` toggles play/pause
231+
232+
```bash
233+
gitlogue --playback-controls --order asc # Enable controls while replaying sequentially
234+
gitlogue --playback-controls=false # Explicitly disable controls
235+
```
236+
237+
Manual controls are most useful with sequential orders (`asc`/`desc`) or commit ranges where you want to narrate each change at your own pace while stepping line-by-line.
238+
220239
### `--help`
221240

222241
Display help information:
@@ -331,6 +350,7 @@ gitlogue diff --unstaged
331350
| `-t, --theme <NAME>` | Theme to use |
332351
| `--background[=BOOL]` | Show background colors (use `--background=false` for transparent) |
333352
| `--loop[=BOOL]` | Loop the animation continuously |
353+
| `--playback-controls[=BOOL]` | Enable manual playback controls inside diff mode |
334354
| `-i, --ignore <PATTERN>` | Ignore files matching pattern (can be specified multiple times) |
335355
| `--speed-rule <PATTERN:MS>` | Set typing speed for files matching pattern |
336356

@@ -363,6 +383,13 @@ While gitlogue is running:
363383

364384
- `Esc` - Quit the application
365385
- `Ctrl+C` - Quit the application
386+
- `Ctrl+j` - (with `--playback-controls`) jump to the previous commit
387+
- `Ctrl+l` - (with `--playback-controls`) jump to the next commit
388+
- `j` - (with `--playback-controls`) step to the previous line chunk
389+
- `l` - (with `--playback-controls`) step to the next line chunk
390+
- `J` - (with `--playback-controls`) jump to the previous change/file
391+
- `L` - (with `--playback-controls`) jump to the next change/file
392+
- `k` - (with `--playback-controls`) toggle play/pause
366393

367394
## Use Cases
368395

src/animation.rs

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::time::{Duration, Instant};
33

44
use globset::{Glob, GlobMatcher};
55
use rand::RngExt;
6+
use std::collections::VecDeque;
67
use unicode_width::UnicodeWidthStr;
78

89
use crate::git::{CommitMetadata, DiffHunk, FileChange, FileStatus, LineChangeType};
@@ -61,6 +62,9 @@ const GIT_PUSH_PAUSE: f64 = 16.7; // After git push command
6162
const PUSH_OUTPUT_PAUSE: f64 = 10.0; // Between push output lines
6263
const PUSH_FINAL_PAUSE: f64 = 66.7; // After final push output
6364

65+
const MAX_LINE_CHECKPOINTS: usize = 200;
66+
const MAX_CHANGE_CHECKPOINTS: usize = 64;
67+
6468
/// Represents the current state of the editor buffer
6569
#[derive(Debug, Clone)]
6670
pub struct EditorBuffer {
@@ -214,6 +218,47 @@ pub enum ActivePane {
214218
Terminal,
215219
}
216220

221+
#[derive(Debug, Clone, Copy, PartialEq)]
222+
pub enum StepMode {
223+
Line,
224+
Change,
225+
}
226+
227+
#[derive(Clone)]
228+
struct ManualCheckpoint {
229+
step_index: usize,
230+
buffer: EditorBuffer,
231+
current_file_index: usize,
232+
current_file_path: Option<String>,
233+
terminal_lines: Vec<String>,
234+
active_pane: ActivePane,
235+
line_offset: isize,
236+
dialog_title: Option<String>,
237+
dialog_typing_text: String,
238+
}
239+
240+
impl ManualCheckpoint {
241+
fn new(engine: &AnimationEngine) -> Self {
242+
Self {
243+
step_index: engine.current_step,
244+
buffer: engine.buffer.clone(),
245+
current_file_index: engine.current_file_index,
246+
current_file_path: engine.current_file_path.clone(),
247+
terminal_lines: engine.terminal_lines.clone(),
248+
active_pane: engine.active_pane.clone(),
249+
line_offset: engine.line_offset,
250+
dialog_title: engine.dialog_title.clone(),
251+
dialog_typing_text: engine.dialog_typing_text.clone(),
252+
}
253+
}
254+
}
255+
256+
#[derive(Clone, Copy, PartialEq)]
257+
enum CheckpointKind {
258+
Line,
259+
Change,
260+
}
261+
217262
/// Main animation engine
218263
pub struct AnimationEngine {
219264
pub buffer: EditorBuffer,
@@ -253,6 +298,9 @@ pub struct AnimationEngine {
253298
pending_metadata: Option<CommitMetadata>,
254299
/// Speed rules for different file patterns
255300
speed_rules: Vec<SpeedRule>,
301+
paused: bool,
302+
line_checkpoints: VecDeque<ManualCheckpoint>,
303+
change_checkpoints: VecDeque<ManualCheckpoint>,
256304
}
257305

258306
impl AnimationEngine {
@@ -289,9 +337,198 @@ impl AnimationEngine {
289337
current_metadata: None,
290338
pending_metadata: None,
291339
speed_rules: Vec::new(),
340+
paused: false,
341+
line_checkpoints: VecDeque::new(),
342+
change_checkpoints: VecDeque::new(),
292343
}
293344
}
294345

346+
/// Pause the animation playback.
347+
pub fn pause(&mut self) {
348+
self.paused = true;
349+
}
350+
351+
/// Resume animation playback from the current position.
352+
pub fn resume(&mut self) {
353+
if self.paused {
354+
self.paused = false;
355+
let now = Instant::now();
356+
self.last_update = now;
357+
self.last_frame = now;
358+
}
359+
}
360+
361+
/// Execute animation steps manually until the next boundary for the given mode.
362+
pub fn manual_step(&mut self, mode: StepMode) -> bool {
363+
if self.state != AnimationState::Playing {
364+
return false;
365+
}
366+
367+
if self.current_step >= self.steps.len() {
368+
self.state = AnimationState::Finished;
369+
return false;
370+
}
371+
372+
self.pause_until = None;
373+
let mut executed = false;
374+
375+
while self.current_step < self.steps.len() {
376+
let step = self.steps[self.current_step].clone();
377+
self.execute_step(step.clone());
378+
self.current_step += 1;
379+
executed = true;
380+
381+
if self.current_step >= self.steps.len() {
382+
self.state = AnimationState::Finished;
383+
}
384+
385+
if Self::is_boundary_step(&step, mode) {
386+
break;
387+
}
388+
}
389+
390+
if executed {
391+
let now = Instant::now();
392+
self.last_update = now;
393+
self.last_frame = now;
394+
}
395+
396+
executed
397+
}
398+
399+
pub fn restore_line_checkpoint(&mut self) -> bool {
400+
if self.line_checkpoints.len() < 2 {
401+
return false;
402+
}
403+
self.line_checkpoints.pop_back();
404+
if let Some(snapshot) = self.line_checkpoints.back().cloned() {
405+
self.apply_checkpoint(snapshot);
406+
true
407+
} else {
408+
false
409+
}
410+
}
411+
412+
pub fn restore_change_checkpoint(&mut self) -> bool {
413+
if self.change_checkpoints.len() < 2 {
414+
return false;
415+
}
416+
self.change_checkpoints.pop_back();
417+
if let Some(snapshot) = self.change_checkpoints.back().cloned() {
418+
self.apply_checkpoint(snapshot);
419+
true
420+
} else {
421+
false
422+
}
423+
}
424+
425+
fn apply_checkpoint(&mut self, snapshot: ManualCheckpoint) {
426+
self.current_step = snapshot.step_index;
427+
self.buffer = snapshot.buffer;
428+
self.current_file_index = snapshot.current_file_index;
429+
self.current_file_path = snapshot.current_file_path;
430+
self.terminal_lines = snapshot.terminal_lines;
431+
self.active_pane = snapshot.active_pane;
432+
self.line_offset = snapshot.line_offset;
433+
self.dialog_title = snapshot.dialog_title;
434+
self.dialog_typing_text = snapshot.dialog_typing_text;
435+
self.pause_until = None;
436+
self.paused = true;
437+
self.state = AnimationState::Playing;
438+
}
439+
440+
fn is_boundary_step(step: &AnimationStep, mode: StepMode) -> bool {
441+
match mode {
442+
StepMode::Line => matches!(
443+
step,
444+
AnimationStep::Pause { .. }
445+
| AnimationStep::SwitchFile { .. }
446+
| AnimationStep::TerminalPrompt
447+
| AnimationStep::TerminalOutput { .. }
448+
| AnimationStep::ResetState
449+
),
450+
StepMode::Change => match step {
451+
AnimationStep::SwitchFile { .. }
452+
| AnimationStep::TerminalPrompt
453+
| AnimationStep::TerminalOutput { .. }
454+
| AnimationStep::ResetState => true,
455+
AnimationStep::Pause { multiplier } => Self::is_change_pause(*multiplier),
456+
_ => false,
457+
},
458+
}
459+
}
460+
461+
fn handle_step_checkpoint(&mut self, step: &AnimationStep) {
462+
match step {
463+
AnimationStep::ResetState => {
464+
self.clear_checkpoints();
465+
self.record_checkpoint(CheckpointKind::Change);
466+
self.record_checkpoint(CheckpointKind::Line);
467+
}
468+
AnimationStep::SwitchFile { .. } => {
469+
self.line_checkpoints.clear();
470+
self.record_checkpoint(CheckpointKind::Change);
471+
self.record_checkpoint(CheckpointKind::Line);
472+
}
473+
AnimationStep::Pause { multiplier } => {
474+
if self.active_pane == ActivePane::Editor {
475+
self.record_checkpoint(CheckpointKind::Line);
476+
if Self::is_change_pause(*multiplier) {
477+
self.record_checkpoint(CheckpointKind::Change);
478+
}
479+
}
480+
}
481+
_ => {}
482+
}
483+
}
484+
485+
fn is_change_pause(multiplier: f64) -> bool {
486+
(multiplier - HUNK_PAUSE).abs() < f64::EPSILON
487+
}
488+
489+
fn record_checkpoint(&mut self, kind: CheckpointKind) {
490+
if self.current_step == 0 {
491+
return;
492+
}
493+
494+
let snapshot = ManualCheckpoint::new(self);
495+
match kind {
496+
CheckpointKind::Line => {
497+
if self
498+
.line_checkpoints
499+
.back()
500+
.map(|c| c.step_index == snapshot.step_index)
501+
.unwrap_or(false)
502+
{
503+
return;
504+
}
505+
self.line_checkpoints.push_back(snapshot);
506+
if self.line_checkpoints.len() > MAX_LINE_CHECKPOINTS {
507+
self.line_checkpoints.pop_front();
508+
}
509+
}
510+
CheckpointKind::Change => {
511+
if self
512+
.change_checkpoints
513+
.back()
514+
.map(|c| c.step_index == snapshot.step_index)
515+
.unwrap_or(false)
516+
{
517+
return;
518+
}
519+
self.change_checkpoints.push_back(snapshot);
520+
if self.change_checkpoints.len() > MAX_CHANGE_CHECKPOINTS {
521+
self.change_checkpoints.pop_front();
522+
}
523+
}
524+
}
525+
}
526+
527+
fn clear_checkpoints(&mut self) {
528+
self.line_checkpoints.clear();
529+
self.change_checkpoints.clear();
530+
}
531+
295532
/// Set speed rules for file-specific typing speeds
296533
pub fn set_speed_rules(&mut self, rules: Vec<SpeedRule>) {
297534
self.speed_rules = rules;
@@ -621,6 +858,7 @@ impl AnimationEngine {
621858

622859
// Start with empty editor (no file opened yet)
623860
self.buffer = EditorBuffer::new();
861+
self.clear_checkpoints();
624862
}
625863

626864
/// Generate animation steps for a file change
@@ -843,6 +1081,10 @@ impl AnimationEngine {
8431081
pub fn tick(&mut self) -> bool {
8441082
self.update_cursor_blink();
8451083

1084+
if self.paused {
1085+
return true;
1086+
}
1087+
8461088
if self.is_paused() {
8471089
return true;
8481090
}
@@ -923,6 +1165,7 @@ impl AnimationEngine {
9231165
}
9241166

9251167
fn execute_step(&mut self, step: AnimationStep) {
1168+
let step_clone = step.clone();
9261169
// Calculate delay for next step with randomization for typing steps
9271170
let mut rng = rand::rng();
9281171
self.next_step_delay = match &step {
@@ -1072,6 +1315,8 @@ impl AnimationEngine {
10721315
}
10731316
}
10741317

1318+
self.handle_step_checkpoint(&step_clone);
1319+
10751320
// Update scroll to keep cursor centered
10761321
self.update_scroll();
10771322
}

0 commit comments

Comments
 (0)