fix: more fixes, this time scrolling

This commit is contained in:
2026-04-05 19:51:19 +02:00
parent 751947258e
commit 16aefa675e
2 changed files with 224 additions and 16 deletions

View File

@@ -496,15 +496,36 @@ impl EditorBuffer {
} }
} }
/// Ensure cursor is visible using visual line index (for word-wrap aware scrolling).
pub fn ensure_visual_line_visible(&mut self, visual_line: usize, visible_lines: usize, max_visual: usize) {
if visible_lines == 0 {
return;
}
if visual_line < self.scroll_offset {
self.scroll_offset = visual_line;
} else if visual_line >= self.scroll_offset + visible_lines {
self.scroll_offset = (visual_line - visible_lines + 1).min(max_visual);
}
}
pub fn scroll_by(&mut self, delta: isize) { pub fn scroll_by(&mut self, delta: isize) {
let max_scroll = self.line_count().saturating_sub(1); self.scroll_by_clamped(delta, self.line_count().saturating_sub(1));
}
/// Scroll by `delta` visual lines, clamped to `max_visual`.
pub fn scroll_by_clamped(&mut self, delta: isize, max_visual: usize) {
if delta < 0 { if delta < 0 {
self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize); self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize);
} else { } else {
self.scroll_offset = (self.scroll_offset + delta as usize).min(max_scroll); self.scroll_offset = (self.scroll_offset + delta as usize).min(max_visual);
} }
} }
/// Set scroll offset directly, clamped to max.
pub fn set_scroll(&mut self, offset: usize, max_visual: usize) {
self.scroll_offset = offset.min(max_visual);
}
// ── Internal helpers ───────────────────────────────────── // ── Internal helpers ─────────────────────────────────────
fn cursor_char_idx(&self) -> usize { fn cursor_char_idx(&self) -> usize {

View File

@@ -28,6 +28,8 @@ struct EditorState {
is_focused: bool, is_focused: bool,
/// Track drag state for click-and-drag selection /// Track drag state for click-and-drag selection
is_dragging: bool, is_dragging: bool,
/// Scrollbar drag state: Some(offset from thumb top to click y)
scrollbar_drag: Option<f32>,
/// Last click time for double-click detection (ms) /// Last click time for double-click detection (ms)
last_click_time: Option<std::time::Instant>, last_click_time: Option<std::time::Instant>,
last_click_line: usize, last_click_line: usize,
@@ -84,6 +86,11 @@ const GUTTER_TEXT: Color = Color::from_rgb(0.45, 0.48, 0.55);
const CURSOR_COLOR: Color = Color::from_rgb(0.9, 0.9, 0.2); const CURSOR_COLOR: Color = Color::from_rgb(0.9, 0.9, 0.2);
const ACTIVE_LINE_NUM: Color = Color::from_rgb(0.75, 0.78, 0.85); const ACTIVE_LINE_NUM: Color = Color::from_rgb(0.75, 0.78, 0.85);
const SELECTION_BG: Color = Color::from_rgba(0.26, 0.54, 0.79, 0.40); const SELECTION_BG: Color = Color::from_rgba(0.26, 0.54, 0.79, 0.40);
const SCROLLBAR_WIDTH: f32 = 10.0;
const SCROLLBAR_TRACK: Color = Color::from_rgba(0.25, 0.27, 0.32, 0.5);
const SCROLLBAR_THUMB: Color = Color::from_rgba(0.50, 0.53, 0.60, 0.7);
const SCROLLBAR_THUMB_HOVER: Color = Color::from_rgba(0.60, 0.63, 0.70, 0.9);
const MIN_THUMB_HEIGHT: f32 = 20.0;
/// Convert syntect RGBA color to Iced Color. /// Convert syntect RGBA color to Iced Color.
fn syntect_to_iced(c: syntect::highlighting::Color) -> Color { fn syntect_to_iced(c: syntect::highlighting::Color) -> Color {
@@ -141,6 +148,43 @@ fn line_text_for(buf: &EditorBuffer, line_idx: usize) -> String {
.unwrap_or_default() .unwrap_or_default()
} }
/// Count total visual lines across all logical lines, accounting for word wrap.
fn total_visual_lines(buf: &EditorBuffer, max_chars: usize) -> usize {
if max_chars == 0 {
return buf.line_count();
}
let mut total = 0usize;
for line_idx in 0..buf.line_count() {
let text = line_text_for(buf, line_idx);
total += word_wrap_breaks(&text, max_chars).len();
}
total
}
/// Convert a logical (line, col) to a visual line index, accounting for word wrap.
fn logical_to_visual(buf: &EditorBuffer, line: usize, col: usize, max_chars: usize) -> usize {
if max_chars == 0 {
return line;
}
let mut vis = 0usize;
for idx in 0..buf.line_count() {
let text = line_text_for(buf, idx);
let breaks = word_wrap_breaks(&text, max_chars);
if idx == line {
// Find which sub-line the column falls on
for (i, &start) in breaks.iter().enumerate() {
let end = if i + 1 < breaks.len() { breaks[i + 1] } else { usize::MAX };
if (col >= start && col < end) || i + 1 == breaks.len() {
return vis + i;
}
}
return vis;
}
vis += breaks.len();
}
vis
}
/// Map a visual row index (from scroll top) to (logical_line, char_offset). /// Map a visual row index (from scroll top) to (logical_line, char_offset).
/// Returns None if beyond end of buffer. /// Returns None if beyond end of buffer.
fn visual_to_logical( fn visual_to_logical(
@@ -487,6 +531,47 @@ where
} }
} }
} }
// ── Scrollbar ──
let total_vis = total_visual_lines(&buf, max_chars);
let viewport_lines = (bounds.height / metrics.line_height) as usize;
if total_vis > viewport_lines {
let track_height = bounds.height;
let thumb_ratio = viewport_lines as f32 / total_vis as f32;
let thumb_height = (track_height * thumb_ratio).max(MIN_THUMB_HEIGHT);
let max_scroll = total_vis.saturating_sub(viewport_lines);
let scroll_ratio = if max_scroll > 0 { scroll as f32 / max_scroll as f32 } else { 0.0 };
let thumb_y = bounds.y + scroll_ratio * (track_height - thumb_height);
let track_x = bounds.x + bounds.width - SCROLLBAR_WIDTH;
// Track
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle { x: track_x, y: bounds.y, width: SCROLLBAR_WIDTH, height: track_height },
border: iced::Border::default(),
shadow: iced::Shadow::default(),
},
SCROLLBAR_TRACK,
);
// Thumb
let is_hover = state.scrollbar_drag.is_some();
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle {
x: track_x + 1.0,
y: thumb_y,
width: SCROLLBAR_WIDTH - 2.0,
height: thumb_height,
},
border: iced::Border {
radius: (4.0).into(),
..iced::Border::default()
},
shadow: iced::Shadow::default(),
},
if is_hover { SCROLLBAR_THUMB_HOVER } else { SCROLLBAR_THUMB },
);
}
} }
fn on_event( fn on_event(
@@ -510,6 +595,68 @@ where
0 0
}; };
// ── Scrollbar interaction ──
let track_x = bounds.x + bounds.width - SCROLLBAR_WIDTH;
let buf_ref = self.buffer.borrow();
let total_vis = total_visual_lines(&buf_ref, cpl);
let viewport_lines = (bounds.height / metrics.line_height) as usize;
drop(buf_ref);
let has_scrollbar = total_vis > viewport_lines;
if has_scrollbar {
let track_height = bounds.height;
let thumb_ratio = viewport_lines as f32 / total_vis as f32;
let thumb_height = (track_height * thumb_ratio).max(MIN_THUMB_HEIGHT);
let max_scroll = total_vis.saturating_sub(viewport_lines);
// Handle scrollbar drag in progress
if let Some(grab_offset) = state.scrollbar_drag {
match event {
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
if let Some(pos) = cursor.position() {
let local_y = pos.y - bounds.y - grab_offset;
let scroll_range = track_height - thumb_height;
let ratio = if scroll_range > 0.0 { (local_y / scroll_range).clamp(0.0, 1.0) } else { 0.0 };
let new_scroll = (ratio * max_scroll as f32).round() as usize;
self.buffer.borrow_mut().set_scroll(new_scroll, max_scroll);
}
return Status::Captured;
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
state.scrollbar_drag = None;
return Status::Captured;
}
_ => {}
}
}
// Start scrollbar drag
if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) = event {
if let Some(pos) = cursor.position() {
if pos.x >= track_x && pos.x <= bounds.x + bounds.width
&& pos.y >= bounds.y && pos.y <= bounds.y + bounds.height
{
let current_scroll = self.buffer.borrow().scroll_offset();
let scroll_ratio = if max_scroll > 0 { current_scroll as f32 / max_scroll as f32 } else { 0.0 };
let thumb_y = bounds.y + scroll_ratio * (track_height - thumb_height);
if pos.y >= thumb_y && pos.y <= thumb_y + thumb_height {
// Clicked on thumb — start dragging
state.scrollbar_drag = Some(pos.y - thumb_y);
} else {
// Clicked on track — jump to position
let ratio = ((pos.y - bounds.y) / track_height).clamp(0.0, 1.0);
let new_scroll = (ratio * max_scroll as f32).round() as usize;
self.buffer.borrow_mut().set_scroll(new_scroll, max_scroll);
state.scrollbar_drag = Some(thumb_height / 2.0);
}
state.is_focused = true;
return Status::Captured;
}
}
}
}
match event { match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if cursor.is_over(bounds) { if cursor.is_over(bounds) {
@@ -606,7 +753,11 @@ where
-(y / metrics.line_height) as isize -(y / metrics.line_height) as isize
} }
}; };
self.buffer.borrow_mut().scroll_by(lines); let mut buf = self.buffer.borrow_mut();
let vis = (bounds.height / metrics.line_height) as usize;
let total = total_visual_lines(&buf, cpl);
let max_scroll = total.saturating_sub(vis);
buf.scroll_by_clamped(lines, max_scroll);
return Status::Captured; return Status::Captured;
} }
Event::Keyboard(keyboard::Event::KeyPressed { Event::Keyboard(keyboard::Event::KeyPressed {
@@ -617,18 +768,35 @@ where
let is_shift = modifiers.shift(); let is_shift = modifiers.shift();
let is_alt = modifiers.alt(); let is_alt = modifiers.alt();
// Helper: ensure cursor visible accounting for word wrap.
// Must be called while buf is still borrowed mutably.
macro_rules! ensure_vis {
($buf:expr) => {{
let (cl, cc) = $buf.cursor();
let vl = logical_to_visual(&$buf, cl, cc, cpl);
let total = total_visual_lines(&$buf, cpl);
let max_s = total.saturating_sub(vis);
$buf.ensure_visual_line_visible(vl, vis, max_s);
}};
}
match key { match key {
// Cmd+Z = undo, Cmd+Shift+Z = redo // Cmd+Z = undo, Cmd+Shift+Z = redo
keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "z" => { keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "z" => {
{ {
let mut buf = self.buffer.borrow_mut(); let mut buf = self.buffer.borrow_mut();
if is_shift { buf.redo(); } else { buf.undo(); } if is_shift { buf.redo(); } else { buf.undo(); }
ensure_vis!(buf);
} }
self.emit_change(shell); self.emit_change(shell);
} }
// Cmd+Y = redo (Windows convention) // Cmd+Y = redo (Windows convention)
keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "y" => { keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "y" => {
self.buffer.borrow_mut().redo(); {
let mut buf = self.buffer.borrow_mut();
buf.redo();
ensure_vis!(buf);
}
self.emit_change(shell); self.emit_change(shell);
} }
// Cmd+A = select all // Cmd+A = select all
@@ -656,7 +824,10 @@ where
if let Some(text) = if let Some(text) =
clipboard.read(iced::advanced::clipboard::Kind::Standard) clipboard.read(iced::advanced::clipboard::Kind::Standard)
{ {
self.buffer.borrow_mut().insert(&text); let mut buf = self.buffer.borrow_mut();
buf.insert(&text);
ensure_vis!(buf);
drop(buf);
self.emit_change(shell); self.emit_change(shell);
} }
} }
@@ -674,7 +845,7 @@ where
} else { } else {
buf.move_up(); buf.move_up();
} }
buf.ensure_cursor_visible(vis); ensure_vis!(buf);
} }
keyboard::Key::Named(keyboard::key::Named::ArrowDown) => { keyboard::Key::Named(keyboard::key::Named::ArrowDown) => {
let mut buf = self.buffer.borrow_mut(); let mut buf = self.buffer.borrow_mut();
@@ -683,7 +854,7 @@ where
} else { } else {
buf.move_down(); buf.move_down();
} }
buf.ensure_cursor_visible(vis); ensure_vis!(buf);
} }
keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => { keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => {
let mut buf = self.buffer.borrow_mut(); let mut buf = self.buffer.borrow_mut();
@@ -696,7 +867,7 @@ where
} else { } else {
buf.move_left(); buf.move_left();
} }
buf.ensure_cursor_visible(vis); ensure_vis!(buf);
} }
keyboard::Key::Named(keyboard::key::Named::ArrowRight) => { keyboard::Key::Named(keyboard::key::Named::ArrowRight) => {
let mut buf = self.buffer.borrow_mut(); let mut buf = self.buffer.borrow_mut();
@@ -709,7 +880,7 @@ where
} else { } else {
buf.move_right(); buf.move_right();
} }
buf.ensure_cursor_visible(vis); ensure_vis!(buf);
} }
keyboard::Key::Named(keyboard::key::Named::Home) => { keyboard::Key::Named(keyboard::key::Named::Home) => {
let mut buf = self.buffer.borrow_mut(); let mut buf = self.buffer.borrow_mut();
@@ -734,7 +905,7 @@ where
} else { } else {
buf.move_page_up(vis); buf.move_page_up(vis);
} }
buf.ensure_cursor_visible(vis); ensure_vis!(buf);
} }
keyboard::Key::Named(keyboard::key::Named::PageDown) => { keyboard::Key::Named(keyboard::key::Named::PageDown) => {
let mut buf = self.buffer.borrow_mut(); let mut buf = self.buffer.borrow_mut();
@@ -743,35 +914,51 @@ where
} else { } else {
buf.move_page_down(vis); buf.move_page_down(vis);
} }
buf.ensure_cursor_visible(vis); ensure_vis!(buf);
} }
keyboard::Key::Named(keyboard::key::Named::Backspace) => { keyboard::Key::Named(keyboard::key::Named::Backspace) => {
{ {
let mut buf = self.buffer.borrow_mut(); let mut buf = self.buffer.borrow_mut();
if is_alt { if is_alt {
// Option+Backspace = delete word left
buf.select_word_left(); buf.select_word_left();
buf.delete_selection(); buf.delete_selection();
} else { } else {
buf.backspace(); buf.backspace();
} }
ensure_vis!(buf);
} }
self.emit_change(shell); self.emit_change(shell);
} }
keyboard::Key::Named(keyboard::key::Named::Delete) => { keyboard::Key::Named(keyboard::key::Named::Delete) => {
self.buffer.borrow_mut().delete_forward(); {
let mut buf = self.buffer.borrow_mut();
buf.delete_forward();
ensure_vis!(buf);
}
self.emit_change(shell); self.emit_change(shell);
} }
keyboard::Key::Named(keyboard::key::Named::Enter) => { keyboard::Key::Named(keyboard::key::Named::Enter) => {
self.buffer.borrow_mut().insert("\n"); {
let mut buf = self.buffer.borrow_mut();
buf.insert("\n");
ensure_vis!(buf);
}
self.emit_change(shell); self.emit_change(shell);
} }
keyboard::Key::Named(keyboard::key::Named::Tab) => { keyboard::Key::Named(keyboard::key::Named::Tab) => {
self.buffer.borrow_mut().insert(" "); {
let mut buf = self.buffer.borrow_mut();
buf.insert(" ");
ensure_vis!(buf);
}
self.emit_change(shell); self.emit_change(shell);
} }
keyboard::Key::Character(ref c) if !is_cmd => { keyboard::Key::Character(ref c) if !is_cmd => {
self.buffer.borrow_mut().insert(c); {
let mut buf = self.buffer.borrow_mut();
buf.insert(c);
ensure_vis!(buf);
}
self.emit_change(shell); self.emit_change(shell);
} }
_ => return Status::Ignored, _ => return Status::Ignored,