Skip to main content

gwm/tui/state/
config_panel.rs

1//! Settings panel modal state (issue #232; editable in #279).
2//!
3//! Started life as a read-only Configuration overlay (issue #232): the
4//! scroll cursor plus the owned, already-resolved [`crate::config::ConfigRow`]
5//! snapshot the renderer paints. Issue #279 turns it into an editable
6//! **Settings** panel — herdr-style — without dropping that read-only view:
7//!
8//! - **Tabs** ([`SettingsTab`]) split the surface into the editable `Theme`
9//!   and `Tui` categories plus the read-only `All` resolved-config view.
10//! - **A layer selector** ([`SettingsLayer`]) chooses whether an edit lands
11//!   in the per-project `.gwm.toml` or the user-global `config.toml`.
12//! - **Fields** ([`SettingField`]) are real toggles / choices / numeric
13//!   inputs, resolved live against the loaded [`crate::config::Config`].
14//!
15//! The state here stays pure (no I/O): navigation, selection and the input
16//! edit buffer live here and are unit-tested ratatui-free; the actual write
17//! (`config_cli::set_value_at`) and the apply-live reload are orchestrated
18//! by [`crate::tui::App`]. Scroll mirrors the help / Command Logs overlays:
19//! the cursor lives here, `max_scroll` / `max_x_scroll` are republished by
20//! the renderer each frame against the live viewport.
21
22use crate::config::{Config, ConfigRow, ConfigSource};
23use crate::tui::keymap::{Action, KeyStroke, Keymap};
24use crate::tui::modal_keymap::{ModalAction, ModalKeymap};
25
26/// The Settings categories, in tab order. `Theme`, `Worktree`, `Tui` and
27/// `Keys` are editable; `All` is the read-only resolved-config view (the
28/// pre-#279 panel).
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum SettingsTab {
31  /// Theme preset selection (editable).
32  #[default]
33  Theme,
34  /// Worktree naming knobs: base dir + path / branch patterns.
35  Worktree,
36  /// TUI behaviour knobs: sidebar side, open mode + commands, confirm
37  /// countdown.
38  Tui,
39  /// Keymap editor (issue #294): every global action + modal verb, rebound
40  /// via live keystroke capture. Rows are dynamic ([`KeyRow`]), not the
41  /// `&'static [SettingField]` the other editable tabs use.
42  Keys,
43  /// The full resolved config, read-only with source attribution.
44  All,
45}
46
47impl SettingsTab {
48  /// Tabs in display order — the navigation cycle.
49  pub const ALL: [SettingsTab; 5] = [
50    SettingsTab::Theme,
51    SettingsTab::Worktree,
52    SettingsTab::Tui,
53    SettingsTab::Keys,
54    SettingsTab::All,
55  ];
56
57  /// Short tab label shown in the header strip.
58  pub fn label(self) -> &'static str {
59    match self {
60      SettingsTab::Theme => "Theme",
61      SettingsTab::Worktree => "Worktree",
62      SettingsTab::Tui => "TUI",
63      SettingsTab::Keys => "Keys",
64      SettingsTab::All => "All",
65    }
66  }
67
68  /// The editable fields under this tab, in display order. `All` has none
69  /// (it is the read-only resolved view).
70  pub fn fields(self) -> &'static [SettingField] {
71    match self {
72      SettingsTab::Theme => &[SettingField::ThemePreset],
73      SettingsTab::Worktree => &[
74        SettingField::WorktreeBase,
75        SettingField::WorktreePathPattern,
76        SettingField::WorktreeBranchPattern,
77      ],
78      SettingsTab::Tui => &[
79        SettingField::SidebarPosition,
80        SettingField::OpenMode,
81        SettingField::ConfirmCountdown,
82        SettingField::AutoRefreshSecs,
83        SettingField::OpenShellCmd,
84        SettingField::OpenEditorCmd,
85      ],
86      // The Keys tab edits dynamic [`KeyRow`]s, not static fields, and `All`
87      // is read-only.
88      SettingsTab::Keys | SettingsTab::All => &[],
89    }
90  }
91}
92
93/// What a [`KeyRow`] rebinds: a global `View::List` action ([`Action`], chords
94/// allowed) or a contextual modal verb ([`ModalAction`], single-stroke).
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum KeyTarget {
97  /// A global action under `[tui.keys]`.
98  Global(Action),
99  /// A modal verb under `[tui.keys.modal.<context>]`.
100  Modal(ModalAction),
101}
102
103impl KeyTarget {
104  /// The dotted `.gwm.toml` key the rebind writes (`config_cli::set_array_at`).
105  pub fn config_key(self) -> String {
106    match self {
107      KeyTarget::Global(a) => format!("tui.keys.{}", a.slug()),
108      KeyTarget::Modal(m) => format!("tui.keys.modal.{}.{}", m.context().config_path(), m.verb()),
109    }
110  }
111
112  /// Modal verbs are single-stroke (issue #219); global actions accept
113  /// multi-stroke chords. Drives the capture machine's accumulate-vs-commit.
114  pub fn single_only(self) -> bool {
115    matches!(self, KeyTarget::Modal(_))
116  }
117
118  /// Dotted `.gwm.toml` keys for any pre-#290 alias of a global action — to be
119  /// stripped from a legacy config when the canonical slug is (re)written so a
120  /// stale alias can't shadow the new binding (Codex #297 review). Empty for
121  /// modal verbs (they have no compat aliases).
122  pub fn compat_alias_keys(self) -> Vec<String> {
123    match self {
124      KeyTarget::Global(a) => a.compat_alias_slugs().map(|s| format!("tui.keys.{s}")).collect(),
125      KeyTarget::Modal(_) => Vec::new(),
126    }
127  }
128}
129
130/// One row of the Keys tab: a bindable target, its display scope/label, the
131/// current key(s), and the layer that sourced the binding. Built fresh on
132/// panel open by [`build_key_rows`] from the live keymaps.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct KeyRow {
135  /// What this row rebinds.
136  pub target: KeyTarget,
137  /// `"global"` or `"modal.<context-path>"`.
138  pub scope: String,
139  /// The action slug (global) or context-local verb (modal).
140  pub label: String,
141  /// Current key(s), comma-joined (`"j, Down"`); empty when unbound.
142  pub keys: String,
143  /// The layer the binding came from (repo / user / default).
144  pub source: ConfigSource,
145}
146
147/// Build the full Keys-tab row list: every global action (declaration order),
148/// then every modal verb grouped by its context (declaration order). `source_of`
149/// maps a dotted config key to its layer — the App passes the same resolved-row
150/// attribution the `All` tab uses, so a hand-edited or in-TUI-set binding shows
151/// the right `repo`/`user` badge and an untouched one reads `default`.
152pub fn build_key_rows(keymap: &Keymap, modal: &ModalKeymap, source_of: impl Fn(&str) -> ConfigSource) -> Vec<KeyRow> {
153  let mut rows = Vec::new();
154  for action in Action::all() {
155    let target = KeyTarget::Global(action);
156    rows.push(KeyRow {
157      target,
158      scope: "global".to_string(),
159      label: action.slug().to_string(),
160      keys: keymap.keys_display(action),
161      source: source_of(&target.config_key()),
162    });
163  }
164  for action in ModalAction::all() {
165    let target = KeyTarget::Modal(action);
166    rows.push(KeyRow {
167      target,
168      scope: format!("modal.{}", action.context().config_path()),
169      label: action.verb().to_string(),
170      keys: modal.keys_display(action),
171      source: source_of(&target.config_key()),
172    });
173  }
174  rows
175}
176
177/// In-progress live keystroke capture for the selected [`KeyRow`] (issue
178/// #294). Pure: the routing layer feeds strokes in, the App reads
179/// [`Self::as_config_items`] to persist. `single_only` mirrors the target's
180/// kind so the router knows whether to auto-commit (modal) or accumulate a
181/// chord until the user confirms (global).
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct KeyCapture {
184  /// Index into [`ConfigPanel::key_rows`] being rebound.
185  pub row: usize,
186  /// Modal verb → true (one stroke, auto-commit); global → false (chord).
187  pub single_only: bool,
188  /// The strokes captured so far, in order.
189  pub pending: Vec<KeyStroke>,
190}
191
192impl KeyCapture {
193  /// The TOML array elements to write: one chord, the captured strokes
194  /// space-joined (`"g g"`), or an empty list when nothing was captured (an
195  /// unbind). Live capture sets a single binding; alternatives stay a
196  /// hand-edit.
197  pub fn as_config_items(&self) -> Vec<String> {
198    if self.pending.is_empty() {
199      return Vec::new();
200    }
201    vec![self.pending.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(" ")]
202  }
203}
204
205/// Which config layer an edit targets. Both are editable (issue #279):
206/// `Project` writes the repo `.gwm.toml`, `Global` writes the user-level
207/// `~/.config/gwm/config.toml`.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
209pub enum SettingsLayer {
210  /// The repo-local `.gwm.toml` — the default (matches `gwm config set`).
211  #[default]
212  Project,
213  /// The user-global `~/.config/gwm/config.toml`.
214  Global,
215}
216
217impl SettingsLayer {
218  /// Label for the header indicator.
219  pub fn label(self) -> &'static str {
220    match self {
221      SettingsLayer::Project => "project (.gwm.toml)",
222      SettingsLayer::Global => "global (~/.config/gwm)",
223    }
224  }
225
226  /// The [`ConfigSource`] an edit on this layer writes — used to decide
227  /// whether the edit will actually take effect or be shadowed by a
228  /// higher-precedence layer.
229  pub fn source(self) -> ConfigSource {
230    match self {
231      SettingsLayer::Project => ConfigSource::Repo,
232      SettingsLayer::Global => ConfigSource::User,
233    }
234  }
235
236  /// Flip to the other layer.
237  pub fn toggled(self) -> Self {
238    match self {
239      SettingsLayer::Project => SettingsLayer::Global,
240      SettingsLayer::Global => SettingsLayer::Project,
241    }
242  }
243}
244
245/// How a [`SettingField`] is edited.
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum FieldKind {
248  /// Cycle through a fixed set of values (Space / Enter advances).
249  Choice,
250  /// A numeric (`u32`) value edited character-by-character in a buffer.
251  Uint,
252  /// A free-text value edited character-by-character in a buffer.
253  Text,
254}
255
256const SIDEBAR_CHOICES: &[&str] = &["right", "left"];
257const OPEN_MODE_CHOICES: &[&str] = &["shell", "editor", "finder"];
258
259/// One editable setting, resolved live against the loaded [`Config`].
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub enum SettingField {
262  /// `theme.preset` — cycle the built-in palettes.
263  ThemePreset,
264  /// `worktree.base` — the worktree base directory (text).
265  WorktreeBase,
266  /// `worktree.path_pattern` — the worktree dir-name pattern (text).
267  WorktreePathPattern,
268  /// `worktree.branch_pattern` — the branch-name pattern (text).
269  WorktreeBranchPattern,
270  /// `tui.sidebar_position` — left / right.
271  SidebarPosition,
272  /// `tui.open.mode` — shell / editor / finder.
273  OpenMode,
274  /// `tui.confirm_countdown_secs` — numeric input.
275  ConfirmCountdown,
276  /// `tui.auto_refresh_secs` — numeric input, 0 disables.
277  AutoRefreshSecs,
278  /// `tui.open.shell_cmd` — `$SHELL` override (text).
279  OpenShellCmd,
280  /// `tui.open.editor_cmd` — `$EDITOR` override (text).
281  OpenEditorCmd,
282}
283
284impl SettingField {
285  /// Human label shown in the panel.
286  pub fn label(self) -> &'static str {
287    match self {
288      SettingField::ThemePreset => "theme preset",
289      SettingField::WorktreeBase => "base directory",
290      SettingField::WorktreePathPattern => "path pattern",
291      SettingField::WorktreeBranchPattern => "branch pattern",
292      SettingField::SidebarPosition => "sidebar position",
293      SettingField::OpenMode => "open mode",
294      SettingField::ConfirmCountdown => "confirm countdown (s)",
295      SettingField::AutoRefreshSecs => "auto refresh (s)",
296      SettingField::OpenShellCmd => "open shell cmd",
297      SettingField::OpenEditorCmd => "open editor cmd",
298    }
299  }
300
301  /// Dotted config key path the edit writes (`config_cli::set_value_at`).
302  pub fn key_path(self) -> &'static str {
303    match self {
304      SettingField::ThemePreset => "theme.preset",
305      SettingField::WorktreeBase => "worktree.base",
306      SettingField::WorktreePathPattern => "worktree.path_pattern",
307      SettingField::WorktreeBranchPattern => "worktree.branch_pattern",
308      SettingField::SidebarPosition => "tui.sidebar_position",
309      SettingField::OpenMode => "tui.open.mode",
310      SettingField::ConfirmCountdown => "tui.confirm_countdown_secs",
311      SettingField::AutoRefreshSecs => "tui.auto_refresh_secs",
312      SettingField::OpenShellCmd => "tui.open.shell_cmd",
313      SettingField::OpenEditorCmd => "tui.open.editor_cmd",
314    }
315  }
316
317  /// Whether the field is a cyclable choice, a numeric input, or free text.
318  pub fn kind(self) -> FieldKind {
319    match self {
320      SettingField::ThemePreset | SettingField::SidebarPosition | SettingField::OpenMode => FieldKind::Choice,
321      SettingField::ConfirmCountdown | SettingField::AutoRefreshSecs => FieldKind::Uint,
322      SettingField::WorktreeBase
323      | SettingField::WorktreePathPattern
324      | SettingField::WorktreeBranchPattern
325      | SettingField::OpenShellCmd
326      | SettingField::OpenEditorCmd => FieldKind::Text,
327    }
328  }
329
330  fn edit_char_limit(self) -> usize {
331    match self {
332      SettingField::AutoRefreshSecs => 20,
333      SettingField::ConfirmCountdown => 3,
334      _ => 256,
335    }
336  }
337
338  /// The fixed choice set for a `Choice` field. Theme presets come from the
339  /// theme registry; the rest are static. Empty for non-choice fields.
340  pub fn choices(self) -> &'static [&'static str] {
341    match self {
342      SettingField::ThemePreset => crate::tui::theme::preset_names(),
343      SettingField::SidebarPosition => SIDEBAR_CHOICES,
344      SettingField::OpenMode => OPEN_MODE_CHOICES,
345      _ => &[],
346    }
347  }
348
349  /// The current value as a display string, read from the resolved config.
350  pub fn current(self, cfg: &Config) -> String {
351    match self {
352      SettingField::ThemePreset => cfg.theme.preset.clone().unwrap_or_else(|| "default".into()),
353      SettingField::WorktreeBase => cfg.worktree.base.clone(),
354      SettingField::WorktreePathPattern => cfg.worktree.path_pattern.clone(),
355      SettingField::WorktreeBranchPattern => cfg.worktree.branch_pattern.clone(),
356      SettingField::SidebarPosition => cfg.tui.sidebar_position.label().into(),
357      SettingField::OpenMode => match cfg.tui.open.mode {
358        crate::config::TuiOpenMode::Shell => "shell".into(),
359        crate::config::TuiOpenMode::Editor => "editor".into(),
360        crate::config::TuiOpenMode::Finder => "finder".into(),
361      },
362      SettingField::ConfirmCountdown => cfg.tui.confirm_countdown_secs.to_string(),
363      SettingField::AutoRefreshSecs => cfg.tui.auto_refresh_secs.to_string(),
364      SettingField::OpenShellCmd => cfg.tui.open.shell_cmd.clone().unwrap_or_default(),
365      SettingField::OpenEditorCmd => cfg.tui.open.editor_cmd.clone().unwrap_or_default(),
366    }
367  }
368
369  /// The next value for a `Choice` field, wrapping. If the current value is
370  /// not one of the choices (e.g. theme preset is `None`/"default"), the
371  /// first choice is returned. `None` for `Uint` fields.
372  pub fn next_choice(self, cfg: &Config) -> Option<String> {
373    let choices = self.choices();
374    if choices.is_empty() {
375      return None;
376    }
377    let current = self.current(cfg);
378    let idx = choices.iter().position(|c| *c == current);
379    let next = match idx {
380      Some(i) => choices[(i + 1) % choices.len()],
381      None => choices[0],
382    };
383    Some(next.to_string())
384  }
385}
386
387/// Owned state for the Settings overlay: the read-only resolved rows, the
388/// active tab / layer / selection, the optional numeric-input edit buffer,
389/// and the (vertical + horizontal) scroll cursor with renderer-published
390/// bounds.
391#[derive(Debug, Default)]
392pub struct ConfigPanel {
393  /// Resolved config rows (key, value, source) for the read-only `All` tab,
394  /// grouped section-first by the renderer. Also the source-attribution
395  /// lookup behind the editable tabs. Assigned by
396  /// [`crate::tui::App::enter_config_panel`].
397  pub rows: Vec<ConfigRow>,
398  /// Active settings tab (issue #279).
399  pub tab: SettingsTab,
400  /// Which config layer edits target (issue #279).
401  pub layer: SettingsLayer,
402  /// Selected field index within the current editable tab.
403  pub selected: usize,
404  /// When `Some`, the numeric-input edit buffer for the selected `Uint`
405  /// field; keystrokes route here until commit (Enter) or cancel (Esc).
406  pub editing: Option<String>,
407  /// Keys-tab rows (issue #294): every rebindable global action + modal verb
408  /// with its current binding + source. Rebuilt on panel open by the App from
409  /// the live keymaps; empty on the other tabs.
410  pub key_rows: Vec<KeyRow>,
411  /// When `Some`, a live keystroke capture is in progress on the Keys tab;
412  /// strokes route into it until commit / cancel.
413  pub capture: Option<KeyCapture>,
414  /// Vertical scroll offset, in rows. Clamped to `max_scroll`.
415  pub scroll: u16,
416  /// Maximum vertical scroll offset, republished by the renderer each
417  /// frame as `content_rows.saturating_sub(viewport_rows)`.
418  pub max_scroll: u16,
419  /// Horizontal scroll offset, in columns. Clamped to `max_x_scroll`.
420  pub x_scroll: u16,
421  /// Maximum horizontal scroll offset, republished by the renderer.
422  pub max_x_scroll: u16,
423}
424
425impl ConfigPanel {
426  /// An empty overlay at the origin.
427  pub fn new() -> Self {
428    Self::default()
429  }
430
431  /// The editable fields under the active tab (empty on the `All` tab).
432  pub fn fields(&self) -> &'static [SettingField] {
433    self.tab.fields()
434  }
435
436  /// The selected field, if the active tab has any.
437  pub fn selected_field(&self) -> Option<SettingField> {
438    self.fields().get(self.selected).copied()
439  }
440
441  /// The selected Keys-tab row, if the active tab is `Keys`.
442  pub fn selected_key_row(&self) -> Option<&KeyRow> {
443    if self.tab == SettingsTab::Keys {
444      self.key_rows.get(self.selected)
445    } else {
446      None
447    }
448  }
449
450  /// Number of selectable rows in the current tab: the static fields, or the
451  /// dynamic key rows on the Keys tab.
452  fn selectable_count(&self) -> usize {
453    if self.tab == SettingsTab::Keys {
454      self.key_rows.len()
455    } else {
456      self.fields().len()
457    }
458  }
459
460  /// Move to the next tab, wrapping. Resets the field selection and any
461  /// in-progress edit / capture so the new tab starts clean.
462  pub fn next_tab(&mut self) {
463    let idx = SettingsTab::ALL.iter().position(|t| *t == self.tab).unwrap_or(0);
464    self.tab = SettingsTab::ALL[(idx + 1) % SettingsTab::ALL.len()];
465    self.selected = 0;
466    self.editing = None;
467    self.capture = None;
468    self.scroll = 0;
469  }
470
471  /// Move to the previous tab, wrapping. Same reset as [`Self::next_tab`].
472  pub fn prev_tab(&mut self) {
473    let idx = SettingsTab::ALL.iter().position(|t| *t == self.tab).unwrap_or(0);
474    let len = SettingsTab::ALL.len();
475    self.tab = SettingsTab::ALL[(idx + len - 1) % len];
476    self.selected = 0;
477    self.editing = None;
478    self.capture = None;
479    self.scroll = 0;
480  }
481
482  /// Flip the edit target layer (project ↔ global).
483  pub fn toggle_layer(&mut self) {
484    self.layer = self.layer.toggled();
485  }
486
487  /// Select the previous field / key row in the current tab (no-op while
488  /// editing or capturing, or on a tab with no rows).
489  pub fn select_prev(&mut self) {
490    if self.editing.is_some() || self.capture.is_some() {
491      return;
492    }
493    self.selected = self.selected.saturating_sub(1);
494  }
495
496  /// Select the next field / key row in the current tab, clamped to the last.
497  pub fn select_next(&mut self) {
498    if self.editing.is_some() || self.capture.is_some() {
499      return;
500    }
501    let count = self.selectable_count();
502    if count > 0 {
503      self.selected = (self.selected + 1).min(count - 1);
504    }
505  }
506
507  /// Begin editing the selected input field (`Uint` or `Text`), seeding the
508  /// buffer with its current value. No-op if the selected field is a
509  /// `Choice` (those cycle, they are not text-edited).
510  pub fn begin_edit(&mut self, current: &str) {
511    if matches!(
512      self.selected_field().map(SettingField::kind),
513      Some(FieldKind::Uint | FieldKind::Text)
514    ) {
515      self.editing = Some(current.to_string());
516    }
517  }
518
519  /// Append a character to the edit buffer. `Uint` fields take ASCII digits
520  /// only (with per-field caps); `Text` fields take any printable character
521  /// (capped at 256).
522  pub fn push_edit_char(&mut self, c: char) {
523    let field = self.selected_field();
524    let uint = matches!(field.map(SettingField::kind), Some(FieldKind::Uint));
525    let limit = field.map(SettingField::edit_char_limit).unwrap_or(256);
526    if let Some(buf) = self.editing.as_mut() {
527      if uint {
528        if c.is_ascii_digit() && buf.len() < limit {
529          buf.push(c);
530        }
531      } else if !c.is_control() && buf.len() < limit {
532        buf.push(c);
533      }
534    }
535  }
536
537  /// Delete the last character of the edit buffer.
538  pub fn pop_edit_char(&mut self) {
539    if let Some(buf) = self.editing.as_mut() {
540      buf.pop();
541    }
542  }
543
544  /// Cancel the in-progress edit, discarding the buffer.
545  pub fn cancel_edit(&mut self) {
546    self.editing = None;
547  }
548
549  /// Commit the in-progress edit, returning the raw buffer (the caller
550  /// coerces an empty numeric buffer to `"0"`; an empty text buffer is a
551  /// legitimate "unset" value).
552  pub fn take_edit(&mut self) -> Option<String> {
553    self.editing.take()
554  }
555
556  /// The source layer that currently provides `field`'s value, looked up in
557  /// the resolved rows. Drives the "shadowed edit" guidance: editing the
558  /// global layer for a field the repo overrides won't change the effective
559  /// value (repo wins).
560  pub fn field_source(&self, field: SettingField) -> Option<ConfigSource> {
561    self.rows.iter().find(|r| r.key == field.key_path()).map(|r| r.source)
562  }
563
564  // ── Keys tab: live keystroke capture (issue #294) ──────────────────────
565
566  /// Arm a live capture for the selected Keys-tab row. No-op off the Keys tab
567  /// or with no row selected. The capture inherits the row's `single_only`
568  /// flag so the router auto-commits a modal verb but accumulates a global
569  /// chord.
570  pub fn begin_capture(&mut self) {
571    if self.tab != SettingsTab::Keys {
572      return;
573    }
574    if let Some(row) = self.key_rows.get(self.selected) {
575      self.capture = Some(KeyCapture {
576        row: self.selected,
577        single_only: row.target.single_only(),
578        pending: Vec::new(),
579      });
580    }
581  }
582
583  /// Append a captured stroke to the in-progress capture.
584  pub fn capture_push(&mut self, stroke: KeyStroke) {
585    if let Some(cap) = self.capture.as_mut() {
586      cap.pending.push(stroke);
587    }
588  }
589
590  /// Drop the last captured stroke (Backspace during a multi-stroke capture).
591  pub fn capture_pop(&mut self) {
592    if let Some(cap) = self.capture.as_mut() {
593      cap.pending.pop();
594    }
595  }
596
597  /// Cancel the in-progress capture, discarding pending strokes.
598  pub fn cancel_capture(&mut self) {
599    self.capture = None;
600  }
601
602  /// Commit the in-progress capture, returning it for the App to persist.
603  pub fn take_capture(&mut self) -> Option<KeyCapture> {
604    self.capture.take()
605  }
606
607  /// Scroll down one row, never past the last line.
608  pub fn scroll_down(&mut self) {
609    self.scroll = (self.scroll + 1).min(self.max_scroll);
610  }
611
612  /// Scroll up one row, never above the top.
613  pub fn scroll_up(&mut self) {
614    self.scroll = self.scroll.saturating_sub(1);
615  }
616
617  /// Scroll right one column, never past the widest line.
618  pub fn scroll_right(&mut self) {
619    self.x_scroll = (self.x_scroll + 1).min(self.max_x_scroll);
620  }
621
622  /// Scroll left one column, never before the first.
623  pub fn scroll_left(&mut self) {
624    self.x_scroll = self.x_scroll.saturating_sub(1);
625  }
626
627  /// Jump to the first row (`g`).
628  pub fn scroll_to_top(&mut self) {
629    self.scroll = 0;
630  }
631
632  /// Jump to the last row (`G`).
633  pub fn scroll_to_bottom(&mut self) {
634    self.scroll = self.max_scroll;
635  }
636
637  /// Reset the cursor + selection + edit buffer to the origin, keeping the
638  /// resolved rows and the active tab/layer. Called when the overlay opens.
639  pub fn reset(&mut self) {
640    self.scroll = 0;
641    self.x_scroll = 0;
642    self.selected = 0;
643    self.editing = None;
644    self.capture = None;
645  }
646}