Merge branch 'main' into fix/windows-ctrl-slash

This commit is contained in:
GeekMr authored and GitHub committed 2026-09-26 14:33:37 +08:00
commit e5175b4a78
8 files changed
+467

No files matched your search

@@ -0,0 +1,28 @@
# Opt-in cursor motion preference
## Status
Accepted for local implementation, 2026-09-25.
## Context
Expose smooth terminal cursor movement as a dropdown under Appearance / Cursor without changing existing installations.
## Evidence
RuntimeSettings owns persisted runtime preferences. SettingsPane persists keys and broadcasts Changed, and the workspace hot-applies Settings to open TerminalViews. The existing notification-duration selector demonstrates failure rollback.
## Decision
Add the shared enum CursorMotion with stable values `off` and `smooth`. Missing, empty or invalid values resolve to Off. Smooth means the fixed 90 ms silkmux trajectory. Include the key in preference reset. The GPUI adapter reads cached settings and applies changes to existing panes; no PTY restart is involved.
## Rejected alternatives
A boolean loses the explicit option contract. Multiple duration settings are unnecessary for reproducing the approved effect. A new Lua/TOML override would introduce a second source and priority policy without a user need.
## Consequences
Old settings retain instant movement. Saving does not modify cursor shape/blinking or unknown keys. Failed writes restore the visible selection and show an error. Other shells can ignore the additive preference.
## Validation
Shared default/invalid/round-trip/reset tests plus GPUI selection, error rollback, localization and hot-application tests.
## Supersedes
None.
## Revisit when
Additional user-visible motion modes or a deliberate default change are requested.
@@ -0,0 +1,198 @@
//! Visual cursor trajectory; never writes terminal coordinates.
//!
//! Behavioral reference: silkmux CursorMotionTracker / projection retreat guard.
//! Match Flutter Curves.easeOutCubic, including its 0.001 Bezier x tolerance.
use std::collections::VecDeque;
use std::time::Duration;
pub(super) const DURATION: Duration = Duration::from_millis(90);
const INPUT_LIFETIME: Duration = Duration::from_millis(250);
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(super) struct Position {
pub col: f64,
pub row: f64,
}
impl Position {
pub fn new(col: f64, row: f64) -> Self {
Self { col, row }
}
fn distance(self, other: Self) -> f64 {
(self.col - other.col).hypot(self.row - other.row)
}
}
#[derive(Default)]
pub(super) struct CursorMotion {
pub visual: Position,
target: Option<Position>,
from: Position,
started: Duration,
last_time: Duration,
moving: bool,
candidate: Option<(Position, Duration, u32)>,
input_guard: Duration,
authorizations: VecDeque<(Option<f64>, Duration)>,
}
impl CursorMotion {
pub fn reset(&mut self) {
*self = Self::default();
}
pub fn active(&self) -> bool {
self.moving || self.candidate.is_some()
}
/// Input bytes are already encoded by the terminal adapter. These permits
/// only classify explicit editing; they do not predict a logical position.
pub fn note_input(&mut self, bytes: &[u8], now: Duration) {
let budget = match bytes {
b"\x08" | b"\x7f" | b"\x02" | b"\x1b[D" | b"\x1bOD" => Some(Some(2.0)),
b"\x01" | b"\x15" | b"\x17" | b"\x1bb" | b"\x1b[H" | b"\x1bOH" | b"\x1b[1~"
| b"\x1b[7~" | b"\x1b[A" | b"\x1b[B" | b"\x1bOA" | b"\x1bOB" | b"\x1b[1;5D" => {
Some(None)
},
_ => None,
};
self.authorizations.retain(|(_, until)| now < *until);
if let Some(budget) = budget {
if self.authorizations.len() < 8 {
self.authorizations.push_back((budget, now + INPUT_LIFETIME));
}
} else if bytes.first().is_some_and(|b| *b >= 0x20 && *b != 0x7f)
|| matches!(
bytes,
b"\x05"
| b"\x1b[C"
| b"\x1bOC"
| b"\x1b[F"
| b"\x1bOF"
| b"\x1b[4~"
| b"\x1b[8~"
| b"\x1b[3~"
)
{
self.input_guard = now + INPUT_LIFETIME;
}
}
pub fn update(
&mut self,
target: Position,
now: Duration,
enabled: bool,
prompt_guard: bool,
cols: usize,
) -> Position {
let now = now.max(self.last_time);
self.last_time = now;
self.advance(now);
self.authorizations.retain(|(_, until)| now < *until);
let Some(previous) = self.target.filter(|_| enabled) else {
self.snap(target);
return self.visual;
};
if target == previous {
self.candidate = None;
return self.visual;
}
let retreat = (previous.row - target.row) * cols as f64 + previous.col - target.col;
if prompt_guard && retreat > 0.0 {
let bounded = self
.authorizations
.iter()
.position(|(budget, _)| budget.is_some_and(|max| retreat <= max));
if let Some(index) = bounded {
self.authorizations.remove(index);
} else {
let (candidate, since, frames) = self.candidate.get_or_insert((target, now, 0));
if *candidate != target {
(*candidate, *since, *frames) = (target, now, 0);
}
*frames += 1;
if *frames < 4 || now.saturating_sub(*since) < DURATION || now < self.input_guard {
return self.visual;
}
let permit = self
.authorizations
.iter()
.position(|(budget, _)| budget.is_none_or(|max| retreat <= max));
if let Some(index) = permit {
self.authorizations.remove(index);
} else {
self.snap(target);
return self.visual;
}
}
} else if retreat < 0.0 {
self.input_guard = Duration::ZERO;
}
self.candidate = None;
if self.visual.distance(target) > 8.0 {
self.snap(target);
} else {
self.from = self.visual;
self.target = Some(target);
self.started = now;
self.moving = true;
}
self.visual
}
fn snap(&mut self, target: Position) {
self.visual = target;
self.target = Some(target);
self.from = target;
self.moving = false;
self.candidate = None;
}
fn advance(&mut self, now: Duration) {
if !self.moving {
return;
}
let target = self.target.unwrap();
let progress = now.saturating_sub(self.started).as_secs_f64() / DURATION.as_secs_f64();
if progress >= 1.0 {
self.visual = target;
self.moving = false;
} else {
let t = ease_out_cubic(progress);
self.visual = Position::new(
self.from.col + (target.col - self.from.col) * t,
self.from.row + (target.row - self.from.row) * t,
);
}
}
}
fn ease_out_cubic(t: f64) -> f64 {
if t <= 0.0 {
return 0.0;
}
if t >= 1.0 {
return 1.0;
}
let bezier = |a: f64, b: f64, s: f64| {
3.0 * a * (1.0 - s) * (1.0 - s) * s + 3.0 * b * (1.0 - s) * s * s + s * s * s
};
let (mut low, mut high) = (0.0, 1.0);
loop {
let s = (low + high) / 2.0;
let x = bezier(0.215, 0.355, s);
if (t - x).abs() < 0.001 {
return bezier(0.61, 1.0, s);
}
if x < t {
low = s;
} else {
high = s;
}
}
}
#[cfg(test)]
#[path = "cursor_motion/tests.rs"]
mod tests;
@@ -0,0 +1,134 @@
use super::*;
fn at(ms: u64) -> Duration {
Duration::from_millis(ms)
}
fn p(col: f64) -> Position {
Position::new(col, 0.0)
}
fn step(m: &mut CursorMotion, col: f64, ms: u64) -> Position {
m.update(p(col), at(ms), true, false, 80)
}
#[test]
fn flutter_reference_samples_and_endpoints_match() {
for (ms, expected) in [
(0, 0.0),
(15, 0.436340294),
(30, 0.722610222),
(45, 0.875116348),
(60, 0.954034002),
(75, 0.990065658),
(90, 1.0),
] {
assert!((ease_out_cubic(ms as f64 / 90.0) - expected).abs() < 1e-9);
}
let mut motion = CursorMotion::default();
step(&mut motion, 0.0, 0);
step(&mut motion, 4.0, 0);
assert!((step(&mut motion, 4.0, 45).col - 3.500465393).abs() < 1e-8);
assert_eq!(step(&mut motion, 4.0, 90), p(4.0));
assert!(!motion.active());
}
#[test]
fn continuous_targets_and_repeated_or_regressed_frames_do_not_pull_back() {
let mut m = CursorMotion::default();
step(&mut m, 0.0, 0);
step(&mut m, 4.0, 0);
let halfway = step(&mut m, 4.0, 45);
assert_eq!(step(&mut m, 6.0, 45), halfway);
assert_eq!(step(&mut m, 6.0, 20), halfway);
assert!(step(&mut m, 6.0, 60).col > halfway.col);
assert_eq!(step(&mut m, 6.0, 135), p(6.0));
}
#[test]
fn eight_cells_animate_but_euclidean_distance_above_eight_snaps() {
let mut m = CursorMotion::default();
step(&mut m, 0.0, 0);
assert_eq!(step(&mut m, 8.0, 0), p(0.0));
assert!(m.active());
step(&mut m, 8.0, 90);
assert_eq!(step(&mut m, 16.01, 90), p(16.01));
assert!(!m.active());
assert_eq!(
m.update(Position::new(22.01, 6.0), at(90), true, false, 80),
Position::new(22.01, 6.0)
);
}
#[test]
fn disable_reset_and_independent_panes_discard_motion() {
let mut a = CursorMotion::default();
let mut b = CursorMotion::default();
step(&mut a, 0.0, 0);
step(&mut b, 10.0, 0);
step(&mut a, 4.0, 0);
assert_eq!(step(&mut b, 10.0, 45), p(10.0));
assert_eq!(a.update(p(4.0), at(45), false, false, 80), p(4.0));
assert!(!a.active());
a.reset();
assert_eq!(step(&mut a, 5.0, 46), p(5.0));
}
#[test]
fn prompt_reset_is_held_but_explicit_left_gets_one_bounded_permit() {
let mut m = CursorMotion::default();
step(&mut m, 7.0, 0);
m.note_input(b"x", at(1));
assert_eq!(m.update(p(0.0), at(2), true, true, 80), p(7.0));
assert_eq!(m.update(p(8.0), at(30), true, true, 80), p(7.0));
m.update(p(8.0), at(120), true, true, 80);
m.note_input(b"\x1b[D", at(121));
assert_eq!(m.update(p(0.0), at(122), true, true, 80), p(8.0));
assert_eq!(m.update(p(7.0), at(130), true, true, 80), p(8.0));
assert_eq!(m.update(p(7.0), at(220), true, true, 80), p(7.0));
// That authorization cannot grant a second retreat.
assert_eq!(m.update(p(6.0), at(221), true, true, 80), p(7.0));
for ms in [240, 260, 311] {
m.update(p(6.0), at(ms), true, true, 80);
}
assert_eq!(m.visual, p(6.0));
assert!(!m.active());
}
#[test]
fn changing_retreat_candidate_restarts_both_time_and_frame_confirmation() {
let mut m = CursorMotion::default();
step(&mut m, 8.0, 0);
for ms in [1, 30, 60] {
m.update(p(0.0), at(ms), true, true, 80);
}
assert_eq!(m.update(p(1.0), at(91), true, true, 80), p(8.0));
for ms in [100, 110, 120] {
assert_eq!(m.update(p(1.0), at(ms), true, true, 80), p(8.0));
}
assert_eq!(m.update(p(1.0), at(181), true, true, 80), p(1.0));
assert!(!m.active());
}
#[test]
fn expired_input_and_alternate_screen_do_not_authorize_prompt_heuristics() {
let mut m = CursorMotion::default();
step(&mut m, 8.0, 0);
m.note_input(b"\x7f", at(1));
assert_eq!(m.update(p(7.0), at(300), true, true, 80), p(8.0));
// TUI positioning is allowed immediately when prompt guarding is off.
assert_eq!(m.update(p(6.0), at(301), true, false, 80), p(8.0));
assert_eq!(m.update(p(6.0), at(391), true, false, 80), p(6.0));
}
#[test]
fn forward_editing_keys_hold_prompt_redraws_until_the_input_guard_expires() {
for input in [&b"x"[..], b"\x1b[C", b"\x1b[F", b"\x1b[3~"] {
let mut m = CursorMotion::default();
step(&mut m, 8.0, 0);
m.note_input(input, at(1));
for ms in [2, 35, 70, 100, 200, 250] {
assert_eq!(m.update(p(0.0), at(ms), true, true, 80), p(8.0));
}
assert_eq!(m.update(p(0.0), at(251), true, true, 80), p(0.0));
assert!(!m.active());
}
}
@@ -7,6 +7,7 @@ mod answer_reader;
pub mod colors;
mod completion_viewport;
pub(super) mod confirmation;
mod cursor_motion;
pub mod element;
mod event_mailbox;
mod inline_image;
+65
View File
@@ -0,0 +1,65 @@
//! Persistent opt-in for presentation-only terminal cursor movement.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CursorMotion {
#[default]
Off,
Smooth,
}
impl CursorMotion {
pub const VALUES: &'static [&'static str] = &["off", "smooth"];
pub fn from_settings(value: &str) -> Option<Self> {
match value.trim() {
value if value.eq_ignore_ascii_case("off") => Some(Self::Off),
value if value.eq_ignore_ascii_case("smooth") => Some(Self::Smooth),
_ => None,
}
}
pub fn settings_value(self) -> &'static str {
match self {
Self::Off => "off",
Self::Smooth => "smooth",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{RawSettings, RuntimeSettings, apply_updates};
#[test]
fn cursor_motion_is_opt_in_and_invalid_values_preserve_instant_motion() {
for text in [
"",
"cursor_motion=",
"cursor_motion=true",
"cursor_motion=90",
"cursor_motion=unknown",
] {
assert_eq!(
RuntimeSettings::from_raw(&RawSettings::from_text(text)).cursor_motion,
CursorMotion::Off
);
}
}
#[test]
fn cursor_motion_round_trips_independently_of_shape_blink_and_unknown_data() {
for value in CursorMotion::VALUES {
let text = apply_updates(
"cursor_shape=block\ncursor_blink=0\ncustom=keep\n",
&[("cursor_motion", (*value).into())],
);
let loaded = RuntimeSettings::from_raw(&RawSettings::from_text(&text));
assert_eq!(loaded.cursor_motion.settings_value(), *value);
assert_eq!(loaded.cursor_shape, Some(crate::CursorShapeName::Block));
assert_eq!(loaded.cursor_blink, Some(false));
assert!(text.contains("custom=keep"));
}
assert_eq!(CursorMotion::from_settings(" SMOOTH "), Some(CursorMotion::Smooth));
}
}
+7
View File
@@ -19,7 +19,9 @@ mod agent_hooks;
pub use agent_hooks::AgentHook;
mod app_icon;
pub use app_icon::{AppIconName, AppIconPalette};
mod cursor_motion;
mod custom_theme;
pub use cursor_motion::CursorMotion;
pub use custom_theme::{
IndexedPalette, TerminalThemeColors, ThemeAppearance, ThemeDefinition, ThemeEffects,
ThemeLayout, ThemeTypography, ThemeUiColors, ThemeValidationError, foreground_recommendations,
@@ -979,6 +981,7 @@ pub struct RuntimeSettings {
pub ligatures: Ligatures,
pub cursor_shape: Option<CursorShapeName>,
pub cursor_blink: Option<bool>,
pub cursor_motion: CursorMotion,
pub copy_on_select: bool,
/// Maximum retained history for new terminals, without altering open sessions.
pub scrollback_lines: usize,
@@ -1152,6 +1155,10 @@ impl RuntimeSettings {
.unwrap_or_default(),
cursor_shape: raw.value("cursor_shape").and_then(CursorShapeName::from_settings),
cursor_blink: raw.bool_on("cursor_blink"),
cursor_motion: raw
.value("cursor_motion")
.and_then(CursorMotion::from_settings)
.unwrap_or_default(),
copy_on_select: raw.bool_on("copy_on_select").unwrap_or(false),
scrollback_lines: scrolling::scrollback_lines(raw),
scroll_speed: normalize_scroll_speed(
+10
View File
@@ -19,6 +19,7 @@ const RESET_KEYS: &[&str] = &[
"ligatures",
"cursor_shape",
"cursor_blink",
"cursor_motion",
"copy_on_select",
"focus_follows_mouse",
"dim_inactive_panes",
@@ -178,6 +179,15 @@ mod tests {
assert!(!restored.contains("notification_duration="));
}
#[test]
fn resetting_preferences_disables_cursor_motion_and_preserves_unrelated_data() {
let restored = default_settings_text("cursor_motion=smooth\ncustom_data=keep\n");
let runtime = crate::RuntimeSettings::from_raw(&crate::RawSettings::from_text(&restored));
assert_eq!(runtime.cursor_motion, crate::CursorMotion::Off);
assert!(!restored.contains("cursor_motion="));
assert!(restored.contains("custom_data=keep"));
}
#[test]
fn reset_enables_ligatures_even_after_following_a_theme() {
for value in ["off", "theme"] {
+24
View File
@@ -960,6 +960,30 @@ mod tests {
use crate::term::Config;
use crate::term::test::TermSize;
#[test]
fn animation_snapshots_cannot_observe_a_partial_synchronized_update() {
use crate::render::{RenderSnapshot, SnapshotConfig};
let mut term = Term::new(Config::default(), &TermSize::new(20, 2), VoidListener);
let mut stream = StreamProcessor::default();
let cfg = SnapshotConfig { rows: 2, cols: 20 };
stream.feed(&mut term, &VoidListener, b"old");
let before = RenderSnapshot::capture(&term, &cfg);
for bytes in [b"\x1b[?20".as_slice(), b"26h\r", b"new", b"\x1b[10G", b"\x1b[?2026"] {
stream.feed(&mut term, &VoidListener, bytes);
// Animation frames read the grid even without a Wakeup.
let frame = RenderSnapshot::capture(&term, &cfg);
assert_eq!(frame.cursor.as_ref().unwrap().col, before.cursor.as_ref().unwrap().col);
assert_eq!(term.grid()[Line(0)][Column(0)].c, 'o');
}
stream.feed(&mut term, &VoidListener, b"l");
assert_eq!(RenderSnapshot::capture(&term, &cfg).cursor.unwrap().col, 9);
assert_eq!(term.grid()[Line(0)][Column(0)].c, 'n');
stream.feed(&mut term, &VoidListener, b"\x1b[?2026h\rtimeout");
assert_eq!(term.grid()[Line(0)][Column(0)].c, 'n');
stream.stop_sync(&mut term);
assert_eq!(term.grid()[Line(0)][Column(0)].c, 't');
}
#[test]
fn authenticated_hook_frames_survive_every_chunk_boundary_and_reject_other_panes() {
#[derive(Clone, Default)]