hjkl-engine 0.41.2

Vim FSM, motion grammar, and ex commands. Pre-1.0 churn.
Documentation
//! Viewport-math helpers — engine-side relocation of the three
//! viewport-aware methods that lived on `hjkl_buffer::View` through
//! 0.0.41:
//!
//! - `View::ensure_cursor_visible` → [`ensure_cursor_visible`]
//! - `View::cursor_screen_row` → [`cursor_screen_row_from`]
//! - `View::max_top_for_height` → [`max_top_for_height`]
//!
//! 0.0.42 (Patch C-δ.7): The "Viewport on Host" decision excludes
//! viewport math from the `View` trait surface. Pre-0.0.42 the engine
//! reached through to the inherent buffer methods (4 resistant reaches
//! flagged in the 0.0.41 CHANGELOG); this module lifts that math onto
//! engine free fns over `B: Query` + `&dyn FoldProvider` + `&Viewport`.
//! Behavior is byte-for-byte identical to the prior buffer-inherent
//! implementation; the lift is purely a re-homing.
//!
//! The buffer-side `View::ensure_cursor_visible` / `cursor_screen_row`
//! / `max_top_for_height` inherent methods stay in place for now (other
//! call sites — e.g. the buffer's own tests — depend on them). 0.1.0
//! removes the buffer-side copies once every consumer migrates.

use hjkl_buffer::{Viewport, Wrap};

use crate::types::{Cursor, FoldProvider, Query};

/// No-alloc-when-possible borrow of row `r`'s content (trailing separator
/// excluded) — [`hjkl_buffer::rope_line_str`]'s slice, minus the
/// unconditional `.to_string()`. ropey's `From<RopeSlice> for Cow<str>`
/// borrows the chunk when the line is contiguous (the common case: any
/// line shorter than a rope chunk, ~256 bytes) and only falls back to an
/// owned `String` for multi-chunk lines. Rows come from a caller-owned
/// rope snapshot (one `Query::rope` clone per walk).
///
/// `r` must be in-bounds: `ropey` panics otherwise, matching the
/// `Query::line` contract this replaces.
///
/// `pub` inside the crate-private `viewport_math` module so the engine's
/// scrolloff walk (`editor.rs`) reuses the same borrow instead of
/// materializing a `String` per dropped row.
pub fn rope_line_slice<'a>(rope: &'a ropey::Rope, r: usize) -> std::borrow::Cow<'a, str> {
    let start = rope.line_to_byte(r);
    // Content end, separator excluded — mirrors `rope_line_content_end` (the
    // final row runs to `len_bytes`). Computed inline so the row's start byte
    // is resolved once instead of again inside `rope_line_bytes`.
    let end = if r + 1 >= rope.len_lines() {
        rope.len_bytes()
    } else {
        hjkl_buffer::floor_char_boundary(rope, rope.line_to_byte(r + 1).saturating_sub(1))
    };
    rope.byte_slice(start..end).into()
}

/// Bring the cursor into the visible viewport, scrolling by the
/// minimum amount needed. When `viewport.wrap != Wrap::None` and
/// `viewport.text_width > 0`, scrolling is screen-line aware:
/// `top_row` is advanced one visible doc row at a time until the
/// cursor's screen row falls inside the viewport's height.
///
/// Replaces the pre-0.0.42 inherent
/// [`hjkl_buffer::View::ensure_cursor_visible`].
pub fn ensure_cursor_visible<B>(buf: &B, folds: &dyn FoldProvider, viewport: &mut Viewport)
where
    B: Cursor + Query + ?Sized,
{
    let cursor = Cursor::cursor(buf);
    let cursor_row = cursor.line as usize;
    let cursor_col = cursor.col as usize;
    let v = *viewport;
    let wrap_active = !matches!(v.wrap, Wrap::None) && v.text_width > 0;
    if !wrap_active {
        // Re-implement `Viewport::ensure_visible` with the engine's
        // grapheme cursor coords. This mirrors `Viewport::ensure_visible`
        // exactly — kept here so the math doesn't depend on a
        // `Position` that the trait doesn't expose.
        let pos = hjkl_buffer::Position::new(cursor_row, cursor_col);
        viewport.ensure_visible(pos);
        return;
    }
    if v.height == 0 {
        return;
    }
    if cursor_row < v.top_row {
        viewport.top_row = cursor_row;
        viewport.top_col = 0;
        return;
    }
    let height = v.height as usize;
    // Compute the cursor's screen row from the current top ONCE (one linear
    // pass), then push `top_row` down incrementally: each dropped row reduces
    // the screen row by its own visible height. This is O(distance) rather than
    // recomputing `cursor_screen_row_from` (itself O(distance)) every step,
    // which made a big soft-wrapped jump O(distance^2).
    // Cursor above `top_row` is handled by the earlier branch; treat any
    // surprise `None` as "leave the viewport where it is".
    let Some(mut screen) = cursor_screen_row_from(buf, folds, viewport, viewport.top_row) else {
        viewport.top_col = 0;
        return;
    };
    let rope = Query::rope(buf);
    while screen >= height {
        let mut next = viewport.top_row + 1;
        while next <= cursor_row && folds.is_row_hidden(next) {
            next += 1;
        }
        if next > cursor_row {
            viewport.top_row = cursor_row;
            break;
        }
        // Removing rows [top_row, next) from the top of the range drops their
        // visible heights (hidden rows contribute 0). After this, `screen`
        // equals `cursor_screen_row_from(..., next)`.
        for r in viewport.top_row..next {
            if !folds.is_row_hidden(r) {
                let line = rope_line_slice(&rope, r);
                screen -= hjkl_buffer::wrap::wrap_segments(&line, v.text_width, v.wrap).len();
            }
        }
        viewport.top_row = next;
    }
    viewport.top_col = 0;
}

/// Earliest `top_row` such that the buffer's screen rows from `top` to
/// the last row total at least `height`. Lets host-side scrolloff math
/// clamp `top_row` so the buffer never leaves blank rows below the
/// content. When the buffer's total screen rows are smaller than
/// `height` this returns 0.
///
/// Replaces the pre-0.0.42 inherent
/// [`hjkl_buffer::View::max_top_for_height`].
pub fn max_top_for_height<B>(
    buf: &B,
    folds: &dyn FoldProvider,
    viewport: &Viewport,
    height: usize,
) -> usize
where
    B: Query + ?Sized,
{
    if height == 0 {
        return 0;
    }
    let row_count = Query::line_count(buf) as usize;
    if row_count == 0 {
        return 0;
    }
    let last = row_count - 1;
    let mut total = 0usize;
    let mut row = last;
    let v = *viewport;
    let rope = Query::rope(buf);
    loop {
        if !folds.is_row_hidden(row) {
            total += if matches!(v.wrap, Wrap::None) || v.text_width == 0 {
                1
            } else {
                let line = rope_line_slice(&rope, row);
                hjkl_buffer::wrap::wrap_segments(&line, v.text_width, v.wrap).len()
            };
        }
        if total >= height {
            return row;
        }
        if row == 0 {
            return 0;
        }
        row -= 1;
    }
}

/// Cursor's screen row counted from `top` rather than `viewport.top_row`.
///
/// The single source of truth for "which screen row is the cursor on",
/// fold- and wrap-aware. Drives [`ensure_cursor_visible`] (which feeds
/// successive candidate `top` rows), the engine's vertical scrolloff, and
/// the terminal cursor-block placement in [`crate::Editor::cursor_screen_pos`].
/// Under `Wrap::None` each non-hidden doc row counts as exactly one
/// screen row, so the result is the fold-collapsed doc-row delta;
/// `None` only when the cursor sits above `top`.
pub fn cursor_screen_row_from<B>(
    buf: &B,
    folds: &dyn FoldProvider,
    viewport: &Viewport,
    top: usize,
) -> Option<usize>
where
    B: Cursor + Query + ?Sized,
{
    let cursor = Cursor::cursor(buf);
    // Clamp: a per-window cursor goes stale when another view shrinks the
    // shared buffer, and Query::line panics past the last line by spec.
    let cursor_row = (cursor.line as usize).min(Query::line_count(buf).saturating_sub(1) as usize);
    let cursor_col = cursor.col as usize;
    if cursor_row < top {
        return None;
    }
    let v = *viewport;
    let rope = Query::rope(buf);
    let mut screen = 0usize;
    for r in top..=cursor_row {
        if folds.is_row_hidden(r) {
            continue;
        }
        let line = rope_line_slice(&rope, r);
        let segs = hjkl_buffer::wrap::wrap_segments(&line, v.text_width, v.wrap);
        if r == cursor_row {
            let seg_idx = hjkl_buffer::wrap::segment_for_col(&segs, cursor_col);
            return Some(screen + seg_idx);
        }
        screen += segs.len();
    }
    None
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use hjkl_buffer::{Position, View, Wrap};

    use super::*;
    use crate::types::NoopFoldProvider;

    fn vp_wrap(width: u16, height: u16) -> Viewport {
        Viewport {
            top_row: 0,
            top_col: 0,
            width,
            height,
            wrap: Wrap::Char,
            text_width: width,
            tab_width: 0,
        }
    }

    /// Regression (commit 5b99fbdb, engine port of the buffer-side
    /// `cursor_screen_row_survives_shrink_from_other_view`): a per-window
    /// cursor goes stale when *another* view shrinks the shared `Buffer`.
    /// `Query::line` panics past the last line by spec, so
    /// `cursor_screen_row_from` must clamp the cursor row to the live
    /// document instead of indexing off the end.
    #[test]
    fn cursor_row_past_eof_is_clamped_not_panicking() {
        let seed = View::from_str("a\nb\nc\nd\ne");
        let arc = seed.content_arc();
        let mut view_a = View::new_view(Arc::clone(&arc));
        let mut view_b = View::new_view(Arc::clone(&arc));
        view_b.set_cursor(Position::new(4, 0));
        // view_a truncates the shared document to a single row. view_b never
        // moved, so its cursor row 4 is now past EOF.
        view_a.replace_all("a");
        assert_eq!(Query::line_count(&view_b), 1, "shared buffer must shrink");
        assert_eq!(
            Cursor::cursor(&view_b).line,
            4,
            "view_b's cursor must still be the stale row (test precondition)"
        );

        let vp = vp_wrap(4, 3);
        let folds = NoopFoldProvider;
        // Unclamped this panics inside `Query::line(buf, 4)`; clamped it
        // reports the only remaining row's screen row.
        assert_eq!(cursor_screen_row_from(&view_b, &folds, &vp, 0), Some(0));

        // Same stale shape through the scroll path, which feeds candidate
        // tops into `cursor_screen_row_from`.
        let mut vp2 = vp_wrap(4, 3);
        ensure_cursor_visible(&view_b, &folds, &mut vp2); // must not panic
    }

    /// The screen-row walks borrow each row's content without materializing
    /// a `String`, mirroring `rope_line_str`'s content end exactly. The two
    /// separator shapes where a naive borrow diverges: ropey's multi-byte
    /// line separators (stepping back one byte into U+2028 would panic on a
    /// non-char boundary) and CRLF (where the `\r` legitimately stays in the
    /// content). Row 0 of `"abc\u{2028}def\nXYZ"` is `"abc"` — one segment
    /// at width 3 — so the cursor on row 1 sits at screen row 1; including
    /// the separator would report 2. `"abcd\r\nXY"`'s row 0 is `"abcd\r"` —
    /// two segments at width 3 — so the cursor on row 1 sits at screen row 2.
    #[test]
    fn wrap_heights_use_rope_line_str_content_end() {
        let folds = NoopFoldProvider;
        for (text, want) in [("abc\u{2028}def\nXYZ", 1), ("abcd\r\nXY", 2)] {
            let seed = View::from_str(text);
            let arc = seed.content_arc();
            let mut view = View::new_view(Arc::clone(&arc));
            view.set_cursor(Position::new(1, 0));
            let vp = vp_wrap(3, 3);
            assert_eq!(
                cursor_screen_row_from(&view, &folds, &vp, 0),
                Some(want),
                "row 0 of {text:?} must wrap with its separator excluded"
            );
        }
    }
}