Skip to content

Commit 2ab12fd

Browse files
claudesinelaw
authored andcommitted
fix: End/Home keys navigate by visual line when line wrapping is enabled
When line wrapping is enabled, End and Home keys now move to the end/start of the current visual (wrapped) line segment instead of the entire physical line. Pressing End/Home repeatedly traverses through wrapped segments until reaching the physical line boundary. This matches the behavior of editors like VS Code, Notepad, and LibreOffice where navigation follows visual line boundaries. Fixes #979 https://claude.ai/code/session_013hWNMU6WorYT8LwikgVHCb
1 parent 2a4c5d4 commit 2ab12fd

3 files changed

Lines changed: 446 additions & 85 deletions

File tree

crates/fresh-editor/src/app/render.rs

Lines changed: 127 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1985,15 +1985,37 @@ impl Editor {
19851985
split_id: SplitId,
19861986
_estimated_line_length: usize,
19871987
) -> Option<Vec<Event>> {
1988-
// Determine direction and whether this is a selection action
1988+
// Classify the action
1989+
enum VisualAction {
1990+
UpDown { direction: i8, is_select: bool },
1991+
LineEnd { is_select: bool },
1992+
LineStart { is_select: bool },
1993+
}
1994+
19891995
// Note: We don't intercept BlockSelectUp/Down because block selection has
19901996
// special semantics (setting block_anchor) that require the default handler
1991-
let (direction, is_select) = match action {
1992-
Action::MoveUp => (-1i8, false),
1993-
Action::MoveDown => (1, false),
1994-
Action::SelectUp => (-1, true),
1995-
Action::SelectDown => (1, true),
1996-
_ => return None, // Not a visual line movement action
1997+
let visual_action = match action {
1998+
Action::MoveUp => VisualAction::UpDown {
1999+
direction: -1,
2000+
is_select: false,
2001+
},
2002+
Action::MoveDown => VisualAction::UpDown {
2003+
direction: 1,
2004+
is_select: false,
2005+
},
2006+
Action::SelectUp => VisualAction::UpDown {
2007+
direction: -1,
2008+
is_select: true,
2009+
},
2010+
Action::SelectDown => VisualAction::UpDown {
2011+
direction: 1,
2012+
is_select: true,
2013+
},
2014+
Action::MoveLineEnd => VisualAction::LineEnd { is_select: false },
2015+
Action::SelectLineEnd => VisualAction::LineEnd { is_select: true },
2016+
Action::MoveLineStart => VisualAction::LineStart { is_select: false },
2017+
Action::SelectLineStart => VisualAction::LineStart { is_select: true },
2018+
_ => return None, // Not a visual line action
19972019
};
19982020

19992021
// First, collect cursor data we need (to avoid borrow conflicts)
@@ -2003,61 +2025,122 @@ impl Editor {
20032025
.cursors
20042026
.iter()
20052027
.map(|(cursor_id, cursor)| {
2028+
// Check if cursor is at a physical line boundary:
2029+
// - at_line_ending: byte at cursor position is a newline or at buffer end
2030+
// - at_line_start: cursor is at position 0 or preceded by a newline
2031+
let at_line_ending = if cursor.position < state.buffer.len() {
2032+
let bytes = state
2033+
.buffer
2034+
.slice_bytes(cursor.position..cursor.position + 1);
2035+
bytes.first() == Some(&b'\n') || bytes.first() == Some(&b'\r')
2036+
} else {
2037+
true // end of buffer is a boundary
2038+
};
2039+
let at_line_start = if cursor.position == 0 {
2040+
true
2041+
} else {
2042+
let prev = state
2043+
.buffer
2044+
.slice_bytes(cursor.position - 1..cursor.position);
2045+
prev.first() == Some(&b'\n')
2046+
};
20062047
(
20072048
cursor_id,
20082049
cursor.position,
20092050
cursor.anchor,
20102051
cursor.sticky_column,
20112052
cursor.deselect_on_move,
2053+
at_line_ending,
2054+
at_line_start,
20122055
)
20132056
})
20142057
.collect()
20152058
};
20162059

20172060
let mut events = Vec::new();
20182061

2019-
for (cursor_id, position, anchor, sticky_column, deselect_on_move) in cursor_data {
2020-
// Calculate current visual column from cached layout
2021-
// If we can't find the position in the layout, bail out and let the default handler deal with it
2022-
let current_visual_col =
2023-
match self.cached_layout.byte_to_visual_column(split_id, position) {
2024-
Some(col) => col,
2025-
None => return None, // Position not in cached layout, use default handler
2026-
};
2062+
for (
2063+
cursor_id,
2064+
position,
2065+
anchor,
2066+
sticky_column,
2067+
deselect_on_move,
2068+
at_line_ending,
2069+
at_line_start,
2070+
) in cursor_data
2071+
{
2072+
let (new_pos, new_sticky) = match &visual_action {
2073+
VisualAction::UpDown { direction, .. } => {
2074+
// Calculate current visual column from cached layout
2075+
let current_visual_col =
2076+
match self.cached_layout.byte_to_visual_column(split_id, position) {
2077+
Some(col) => col,
2078+
None => return None,
2079+
};
20272080

2028-
// Use sticky column if set, otherwise use current visual column
2029-
let goal_visual_col = if sticky_column > 0 {
2030-
sticky_column
2031-
} else {
2032-
current_visual_col
2081+
let goal_visual_col = if sticky_column > 0 {
2082+
sticky_column
2083+
} else {
2084+
current_visual_col
2085+
};
2086+
2087+
match self.cached_layout.move_visual_line(
2088+
split_id,
2089+
position,
2090+
goal_visual_col,
2091+
*direction,
2092+
) {
2093+
Some(result) => result,
2094+
None => continue, // At boundary, skip this cursor
2095+
}
2096+
}
2097+
VisualAction::LineEnd { .. } => {
2098+
// Allow advancing to next visual segment only if not at a physical line ending
2099+
let allow_advance = !at_line_ending;
2100+
match self
2101+
.cached_layout
2102+
.visual_line_end(split_id, position, allow_advance)
2103+
{
2104+
Some(end_pos) => (end_pos, 0),
2105+
None => return None,
2106+
}
2107+
}
2108+
VisualAction::LineStart { .. } => {
2109+
// Allow advancing to previous visual segment only if not at a physical line start
2110+
let allow_advance = !at_line_start;
2111+
match self
2112+
.cached_layout
2113+
.visual_line_start(split_id, position, allow_advance)
2114+
{
2115+
Some(start_pos) => (start_pos, 0),
2116+
None => return None,
2117+
}
2118+
}
20332119
};
20342120

2035-
// Try to move using cached layout
2036-
let move_result =
2037-
self.cached_layout
2038-
.move_visual_line(split_id, position, goal_visual_col, direction);
2121+
let is_select = match &visual_action {
2122+
VisualAction::UpDown { is_select, .. } => *is_select,
2123+
VisualAction::LineEnd { is_select } => *is_select,
2124+
VisualAction::LineStart { is_select } => *is_select,
2125+
};
20392126

2040-
if let Some((new_pos, new_sticky)) = move_result {
2041-
// Determine anchor based on action type
2042-
let new_anchor = if is_select {
2043-
Some(anchor.unwrap_or(position))
2044-
} else if deselect_on_move {
2045-
None
2046-
} else {
2047-
anchor
2048-
};
2127+
let new_anchor = if is_select {
2128+
Some(anchor.unwrap_or(position))
2129+
} else if deselect_on_move {
2130+
None
2131+
} else {
2132+
anchor
2133+
};
20492134

2050-
events.push(Event::MoveCursor {
2051-
cursor_id,
2052-
old_position: position,
2053-
new_position: new_pos,
2054-
old_anchor: anchor,
2055-
new_anchor,
2056-
old_sticky_column: sticky_column,
2057-
new_sticky_column: new_sticky,
2058-
});
2059-
}
2060-
// If move_result is None, we're at a boundary - don't generate an event
2135+
events.push(Event::MoveCursor {
2136+
cursor_id,
2137+
old_position: position,
2138+
new_position: new_pos,
2139+
old_anchor: anchor,
2140+
new_anchor,
2141+
old_sticky_column: sticky_column,
2142+
new_sticky_column: new_sticky,
2143+
});
20612144
}
20622145

20632146
if events.is_empty() {

crates/fresh-editor/src/app/types.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,4 +811,53 @@ impl CachedLayout {
811811

812812
Some((new_pos, goal_visual_col))
813813
}
814+
815+
/// Get the start byte position of the visual row containing the given byte position.
816+
/// If the cursor is already at the visual row start and this is a wrapped continuation,
817+
/// moves to the previous visual row's start (within the same logical line).
818+
/// Get the start byte position of the visual row containing the given byte position.
819+
/// When `allow_advance` is true and the cursor is already at the row start,
820+
/// moves to the previous visual row's start.
821+
pub fn visual_line_start(
822+
&self,
823+
split_id: SplitId,
824+
byte_pos: usize,
825+
allow_advance: bool,
826+
) -> Option<usize> {
827+
let mappings = self.view_line_mappings.get(&split_id)?;
828+
let row_idx = self.find_visual_row(split_id, byte_pos)?;
829+
let row = mappings.get(row_idx)?;
830+
let row_start = row.first_source_byte()?;
831+
832+
if allow_advance && byte_pos == row_start && row_idx > 0 {
833+
let prev_row = mappings.get(row_idx - 1)?;
834+
prev_row.first_source_byte()
835+
} else {
836+
Some(row_start)
837+
}
838+
}
839+
840+
/// Get the end byte position of the visual row containing the given byte position.
841+
/// If the cursor is already at the visual row end and the next row is a wrapped continuation,
842+
/// moves to the next visual row's end (within the same logical line).
843+
/// Get the end byte position of the visual row containing the given byte position.
844+
/// When `allow_advance` is true and the cursor is already at the row end,
845+
/// advances to the next visual row's end.
846+
pub fn visual_line_end(
847+
&self,
848+
split_id: SplitId,
849+
byte_pos: usize,
850+
allow_advance: bool,
851+
) -> Option<usize> {
852+
let mappings = self.view_line_mappings.get(&split_id)?;
853+
let row_idx = self.find_visual_row(split_id, byte_pos)?;
854+
let row = mappings.get(row_idx)?;
855+
856+
if allow_advance && byte_pos == row.line_end_byte && row_idx + 1 < mappings.len() {
857+
let next_row = mappings.get(row_idx + 1)?;
858+
Some(next_row.line_end_byte)
859+
} else {
860+
Some(row.line_end_byte)
861+
}
862+
}
814863
}

0 commit comments

Comments
 (0)