Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions src/app/count.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
use std::time::Instant;

use super::types::LEADER_WINDOW;

const DIGIT_THRESHOLD: u32 = 8;

/// Numeric prefix (vim-style count) state machine. Digits accumulate via
/// `push_digit`, up to an 8-digit cap, and `take` consumes the pending
/// count (e.g. `5j`, `12G`).
#[derive(Debug, Default, Clone, Copy)]
pub struct Count {
pending: Option<(u32, Instant)>,
}

impl Count {
/// Accumulates a digit onto the pending count. Digits beyond the
/// 8-digit cap are ignored, at most 8 digits are kept.
pub fn push_digit(&mut self, d: char) {
let digit = d
.to_digit(10)
.expect("push_digit called with a non-digit char");
let base = self.active().unwrap_or(0);

// ilog10 returns the power of 10, to obtain the actual count of digits
// in a number, we have to add 1 to it
let digit_count = base.checked_ilog10().unwrap_or(0) + 1;

if digit_count >= DIGIT_THRESHOLD {
self.pending = Some((base, Instant::now()));
return;
}

let n = base * 10 + digit;
self.pending = Some((n, Instant::now()));
}

/// Currently pending count, or None if expired or absent.
pub fn active(&self) -> Option<u32> {
self.pending
.filter(|(_, t)| t.elapsed() < LEADER_WINDOW)
.map(|(n, _)| n)
}

/// Returns the pending count and clears it, consuming it like
/// `Option::take`.
pub fn take(&mut self) -> Option<u32> {
let n = self.active();
self.clear();
n
}

/// True when a count is pending but has expired — the event loop uses
/// this to trigger a redraw so the status-bar indicator clears.
pub fn should_clear(&self) -> bool {
self.pending
.as_ref()
.map(|(_, t)| t.elapsed() >= LEADER_WINDOW)
.unwrap_or(false)
}

pub fn clear(&mut self) {
self.pending = None;
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;

#[test]
fn count_push_digit_accumulates_multiple_digits() {
let mut c = Count::default();
c.push_digit('1');
c.push_digit('4');
assert_eq!(c.active(), Some(14));
}

#[test]
fn count_push_digit_starts_fresh_after_stale_pending() {
let stale = Instant::now() - LEADER_WINDOW - Duration::from_millis(10);
let mut c = Count {
pending: Some((1, stale)),
};
// The stale '1' must not leak into the new number: typing '4' after
// the window expired should yield 4, not 14.
c.push_digit('4');
assert_eq!(c.active(), Some(4));
}

#[test]
fn count_push_digit_caps_at_eight_digits() {
let mut c = Count::default();
for d in ['1', '2', '3', '4', '5', '6', '7', '8', '9'] {
c.push_digit(d);
}
// The 9th digit (and any further one) is dropped: the number stays
// at its 8-digit cap instead of growing or overflowing.
assert_eq!(c.active(), Some(12345678));
c.push_digit('5');
assert_eq!(c.active(), Some(12345678));
}

#[test]
fn count_active_expires_after_window() {
let stale = Instant::now() - LEADER_WINDOW - Duration::from_millis(10);
let c = Count {
pending: Some((7, stale)),
};
assert!(c.active().is_none());
assert!(c.should_clear());
}
}
4 changes: 4 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use crate::todo::Task;
mod autocomplete;
mod bulk;
mod chord;
mod count;
mod draft;
mod draft_overlay;
mod flash;
Expand All @@ -35,6 +36,7 @@ pub use crate::core::History;
pub use crate::core::filter::{ListDueBucket, ordered_unique};
pub use autocomplete::{ActiveToken, AutocompleteTarget, TokenKind, active_token};
pub use chord::Chord;
pub use count::Count;
pub use draft::{DialogInputMode, DraftCursor, DraftState};
pub use draft_overlay::{
BuilderField, CalendarState, CalendarTarget, DraftOverlay, OverlayKind, PriorityChooserState,
Expand Down Expand Up @@ -106,6 +108,7 @@ pub struct App {
pub selection: Selection,
flash_state: Flash,
pub chord: Chord,
pub count: Count,
pub file_path: PathBuf,
/// Resolved path of the on-disk config file. Set by the binary after
/// construction so the settings overlay can render a stable, real path
Expand Down Expand Up @@ -203,6 +206,7 @@ impl App {
selection: Selection::default(),
flash_state: Flash::default(),
chord: Chord::default(),
count: Count::default(),
file_path,
config_path: None,
should_quit: false,
Expand Down
41 changes: 37 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,10 @@ fn run(
app.chord.clear();
dirty = true;
}
if app.count.should_clear() {
app.count.clear();
dirty = true;
}
}
Ok(())
}
Expand Down Expand Up @@ -957,6 +961,12 @@ fn resolve_normal_key(app: &mut App, key: KeyEvent, keybinds: &KeyBindings) -> O
_ => None,
};
}

if let KeyCode::Char(c @ '0'..='9') = key.code {
app.count.push_digit(c);
return None;
}

Some(match key.code {
KeyCode::Char('q') => Action::Quit,
KeyCode::Char('j') | KeyCode::Down => Action::CursorDown,
Expand Down Expand Up @@ -1031,6 +1041,7 @@ fn resolve_normal_key(app: &mut App, key: KeyEvent, keybinds: &KeyBindings) -> O
KeyCode::Char('F') => Action::ToggleShowFuture,
KeyCode::Esc => Action::EscapeStack,
KeyCode::Char('W') => Action::ChangeWeekStart,

_ => return None,
})
}
Expand Down Expand Up @@ -1083,14 +1094,36 @@ fn apply_action(app: &mut App, action: Action) {
Action::Quit => app.should_quit = true,
Action::CursorDown => {
if len > 0 {
app.cursor = (app.cursor + 1).min(len - 1);
app.cursor = (app.cursor + app.count.take().unwrap_or(1) as usize).min(len - 1);
}
}
Action::CursorUp => app.cursor = app.cursor.saturating_sub(1),
Action::CursorTop => app.cursor = 0,
Action::CursorUp => {
app.cursor = app
.cursor
.saturating_sub(app.count.take().unwrap_or(1) as usize)
}
Action::CursorTop => {
// `0` is skipped (same as plain `gg`), matching vim. A count
// beyond the list length clamps to the last item instead of
// going out of bounds; on an empty list it just stays at 0.
let n = app.count.take().unwrap_or(0) as usize;
app.cursor = if n == 0 || len == 0 {
0
} else {
(n - 1).min(len - 1)
};
}
Action::CursorBottom => {
if len > 0 {
app.cursor = len - 1;
// skipping on '0' is acceptable as it's the same in vim
if let n = app.count.take().unwrap_or(0) as usize
&& n < len
&& n != 0
{
app.cursor = n - 1;
} else {
app.cursor = len - 1;
}
}
}
Action::HalfPageDown => {
Expand Down
13 changes: 12 additions & 1 deletion src/ui/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,19 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) {
.active()
.map(|c| format!(" {c}…"))
.unwrap_or_default();

// Append a count indicator (e.g. " 12…") so the motion sequences like 12j/5G
// give visible feedback when pressed. Only shown while active.
let count_suffix = app
.count
.active()
.map(|c| format!(" {c}…"))
.unwrap_or_default();

// Layout: mode chip on left, hint in middle, right text right-aligned.
let chip_text = format!(" {mode_label}{chord_suffix} ");
// Count comes before chord since a count prefix is typed first (e.g.
// `5gg`), matching the order keys are actually pressed.
let chip_text = format!(" {mode_label}{count_suffix}{chord_suffix} ");
let chip_w = chip_text.chars().count() as u16;
let update_w = update_suffix
.as_deref()
Expand Down