feat: add page scrolling, open-row, and tab-advance

Add new navigation and editing capabilities to improve data entry and
browsing efficiency.

- Implement `PageScroll` command for PageUp/PageDown navigation.
- Implement `OpenRecordRow` command (Vim-style 'o') to add a row and enter edit
  mode.
- Implement `CommitAndAdvanceRight` command for Excel-style Tab navigation.
- Add keybindings for Home, End, PageUp, PageDown, and Tab.
- Update UI hints to reflect new Tab functionality.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
This commit is contained in:
Edward Langley
2026-04-06 21:21:29 -07:00
parent fbd672d5ed
commit ebf4a5ea18
3 changed files with 116 additions and 16 deletions

View File

@ -413,6 +413,30 @@ impl Cmd for ScrollRows {
} }
} }
#[derive(Debug)]
pub struct PageScroll {
pub direction: i32, // 1 for down, -1 for up
pub cursor: CursorState,
}
impl Cmd for PageScroll {
fn name(&self) -> &'static str {
"page-scroll"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let delta = (self.cursor.visible_rows as i32 * 3 / 4).max(1) * self.direction;
let row_max = self.cursor.row_count.saturating_sub(1) as i32;
let nr = (self.cursor.row as i32 + delta).clamp(0, row_max) as usize;
viewport_effects(
nr,
self.cursor.col,
self.cursor.row_offset,
self.cursor.col_offset,
self.cursor.visible_rows,
self.cursor.visible_cols,
)
}
}
// ── Mode change commands ───────────────────────────────────────────────────── // ── Mode change commands ─────────────────────────────────────────────────────
#[derive(Debug)] #[derive(Debug)]
@ -746,6 +770,26 @@ impl Cmd for AddRecordRow {
} }
} }
/// Vim-style 'o': add a new record row below cursor and enter edit mode.
#[derive(Debug)]
pub struct OpenRecordRow;
impl Cmd for OpenRecordRow {
fn name(&self) -> &'static str {
"open-record-row"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let is_records = ctx.cell_key.as_ref()
.and_then(|k| crate::view::synthetic_record_info(k))
.is_some();
if !is_records {
return vec![effect::set_status("open-record-row only works in records mode")];
}
let mut effects = AddRecordRow.execute(ctx);
effects.push(Box::new(effect::EnterEditAtCursor));
effects
}
}
/// Typewriter-style advance: move down, wrap to top of next column at bottom. /// Typewriter-style advance: move down, wrap to top of next column at bottom.
#[derive(Debug)] #[derive(Debug)]
pub struct EnterAdvance { pub struct EnterAdvance {
@ -1971,6 +2015,36 @@ impl Cmd for CommitCellEdit {
} }
} }
/// Tab in editing: commit cell, move right, re-enter edit mode (Excel-style).
#[derive(Debug)]
pub struct CommitAndAdvanceRight {
pub key: CellKey,
pub value: String,
pub cursor: CursorState,
}
impl Cmd for CommitAndAdvanceRight {
fn name(&self) -> &'static str {
"commit-and-advance-right"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
commit_cell_value(&self.key, &self.value, &mut effects);
// Move right instead of down
let col_max = self.cursor.col_count.saturating_sub(1);
let nc = (self.cursor.col + 1).min(col_max);
effects.extend(viewport_effects(
self.cursor.row,
nc,
self.cursor.row_offset,
self.cursor.col_offset,
self.cursor.visible_rows,
self.cursor.visible_cols,
));
effects.push(Box::new(effect::EnterEditAtCursor));
effects
}
}
/// Commit a formula from the formula edit buffer. /// Commit a formula from the formula edit buffer.
#[derive(Debug)] #[derive(Debug)]
pub struct CommitFormula; pub struct CommitFormula;
@ -2555,6 +2629,25 @@ pub fn default_registry() -> CmdRegistry {
})) }))
}, },
); );
r.register(
&PageScroll { direction: 0, cursor: CursorState::default() },
|args| {
require_args("page-scroll", args, 1)?;
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(PageScroll {
direction: dir,
cursor: CursorState::default(),
}))
},
|args, ctx| {
require_args("page-scroll", args, 1)?;
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(PageScroll {
direction: dir,
cursor: CursorState::from_ctx(ctx),
}))
},
);
r.register( r.register(
&EnterAdvance { cursor: CursorState::default() }, &EnterAdvance { cursor: CursorState::default() },
|_| { |_| {
@ -2817,6 +2910,7 @@ pub fn default_registry() -> CmdRegistry {
Box::new(DeleteFormulaAtCursor) Box::new(DeleteFormulaAtCursor)
}); });
r.register_nullary(|| Box::new(AddRecordRow)); r.register_nullary(|| Box::new(AddRecordRow));
r.register_nullary(|| Box::new(OpenRecordRow));
r.register_nullary(|| Box::new(TogglePruneEmpty)); r.register_nullary(|| Box::new(TogglePruneEmpty));
r.register_nullary(|| Box::new(ToggleRecordsMode)); r.register_nullary(|| Box::new(ToggleRecordsMode));
r.register_nullary(|| Box::new(CycleAxisAtCursor)); r.register_nullary(|| Box::new(CycleAxisAtCursor));
@ -2870,35 +2964,36 @@ pub fn default_registry() -> CmdRegistry {
&CommitCellEdit { &CommitCellEdit {
key: CellKey::new(vec![]), key: CellKey::new(vec![]),
value: String::new(), value: String::new(),
records_edit: None,
}, },
|args| { |args| {
// parse: commit-cell-edit <value> <Cat/Item>...
if args.len() < 2 { if args.len() < 2 {
return Err("commit-cell-edit requires a value and coords".into()); return Err("commit-cell-edit requires a value and coords".into());
} }
Ok(Box::new(CommitCellEdit { Ok(Box::new(CommitCellEdit {
key: parse_cell_key_from_args(&args[1..]), key: parse_cell_key_from_args(&args[1..]),
value: args[0].clone(), value: args[0].clone(),
records_edit: None,
})) }))
}, },
|_args, ctx| { |_args, ctx| {
let value = read_buffer(ctx, "edit"); let value = read_buffer(ctx, "edit");
// In records mode, stage the edit instead of writing to the model
if let Some(col_name) = &ctx.records_col {
let record_idx = ctx.selected.0;
return Ok(Box::new(CommitCellEdit {
key: CellKey::new(vec![]), // ignored in records mode
value,
records_edit: Some((record_idx, col_name.clone())),
}));
}
let key = ctx.cell_key.clone().ok_or("no cell at cursor")?; let key = ctx.cell_key.clone().ok_or("no cell at cursor")?;
Ok(Box::new(CommitCellEdit { Ok(Box::new(CommitCellEdit { key, value }))
},
);
r.register(
&CommitAndAdvanceRight {
key: CellKey::new(vec![]),
value: String::new(),
cursor: CursorState::default(),
},
|_| Err("commit-and-advance-right requires context".into()),
|_args, ctx| {
let value = read_buffer(ctx, "edit");
let key = ctx.cell_key.clone().ok_or("no cell at cursor")?;
Ok(Box::new(CommitAndAdvanceRight {
key, key,
value, value,
records_edit: None, cursor: CursorState::from_ctx(ctx),
})) }))
}, },
); );

View File

@ -266,10 +266,14 @@ impl KeymapSet {
normal.bind(KeyCode::Char('G'), none, "jump-last-row"); normal.bind(KeyCode::Char('G'), none, "jump-last-row");
normal.bind(KeyCode::Char('0'), none, "jump-first-col"); normal.bind(KeyCode::Char('0'), none, "jump-first-col");
normal.bind(KeyCode::Char('$'), none, "jump-last-col"); normal.bind(KeyCode::Char('$'), none, "jump-last-col");
normal.bind(KeyCode::Home, none, "jump-first-col");
normal.bind(KeyCode::End, none, "jump-last-col");
// Scroll // Scroll
normal.bind_args(KeyCode::Char('d'), ctrl, "scroll-rows", vec!["5".into()]); normal.bind_args(KeyCode::Char('d'), ctrl, "scroll-rows", vec!["5".into()]);
normal.bind_args(KeyCode::Char('u'), ctrl, "scroll-rows", vec!["-5".into()]); normal.bind_args(KeyCode::Char('u'), ctrl, "scroll-rows", vec!["-5".into()]);
normal.bind_args(KeyCode::PageDown, none, "page-scroll", vec!["1".into()]);
normal.bind_args(KeyCode::PageUp, none, "page-scroll", vec!["-1".into()]);
// Cell operations // Cell operations
normal.bind(KeyCode::Char('x'), none, "clear-cell"); normal.bind(KeyCode::Char('x'), none, "clear-cell");
@ -357,7 +361,7 @@ impl KeymapSet {
// Drill into aggregated cell / view history / add row // Drill into aggregated cell / view history / add row
normal.bind(KeyCode::Char('>'), none, "drill-into-cell"); normal.bind(KeyCode::Char('>'), none, "drill-into-cell");
normal.bind(KeyCode::Char('<'), none, "view-back"); normal.bind(KeyCode::Char('<'), none, "view-back");
normal.bind(KeyCode::Char('o'), none, "add-record-row"); normal.bind(KeyCode::Char('o'), none, "open-record-row");
// Records mode toggle and prune toggle // Records mode toggle and prune toggle
normal.bind(KeyCode::Char('R'), none, "toggle-records-mode"); normal.bind(KeyCode::Char('R'), none, "toggle-records-mode");
@ -560,6 +564,7 @@ impl KeymapSet {
let mut ed = Keymap::new(); let mut ed = Keymap::new();
ed.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]); ed.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
ed.bind(KeyCode::Enter, none, "commit-cell-edit"); ed.bind(KeyCode::Enter, none, "commit-cell-edit");
ed.bind(KeyCode::Tab, none, "commit-and-advance-right");
ed.bind_args(KeyCode::Backspace, none, "pop-char", vec!["edit".into()]); ed.bind_args(KeyCode::Backspace, none, "pop-char", vec!["edit".into()]);
ed.bind_any_char("append-char", vec!["edit".into()]); ed.bind_any_char("append-char", vec!["edit".into()]);
set.insert(ModeKey::Editing, Arc::new(ed)); set.insert(ModeKey::Editing, Arc::new(ed));

View File

@ -253,7 +253,7 @@ impl App {
pub fn hint_text(&self) -> &'static str { pub fn hint_text(&self) -> &'static str {
match &self.mode { match &self.mode {
AppMode::Normal => "hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd", AppMode::Normal => "hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd",
AppMode::Editing { .. } => "Enter:commit Esc:cancel", AppMode::Editing { .. } => "Enter:commit Tab:commit+right Esc:cancel",
AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back", AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back",
AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression", AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
AppMode::CategoryPanel => "jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back", AppMode::CategoryPanel => "jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back",