Three intertwined fixes for the no-VHI scroll path that together eliminate
the 'switching to a new entry scrolls it to viewport top' behavior observed
during Loading state, Loading->Ready transition window, and Tab-toggle
VHI rebuild window.
Cursor/viewport decoupling (the user-visible bug):
* scroll_down_line / scroll_up_line no-VHI branches now advance only the
cursor (cursor_line, cursor_sub_offset), then call ensure_cursor_visible_no_vhi
to adjust the viewport minimally if needed. Previously they locked
v_offset = cursor_line, forcing the cursor's entry to the top of the
viewport on every keypress.
* New helpers: advance_visual_pos, retreat_visual_pos (bounded visual-row
walks using compute_visual_height), ensure_cursor_visible_no_vhi (matches
the VHI-path semantics - top-align when cursor is above viewport,
bottom-align with minimal scroll when below, no movement when within).
* Same pattern applied to scroll_down/up_half_page and scroll_down/up_page.
* scroll_to_bottom sets cursor_sub_offset to the last visual row of the
last line in no-VHI state (was 0).
* ensure_viewport_cache hidden anchors removed: the params-change recenter
and the Loading post-check that did v_offset = cursor_line are replaced
with the visibility helper.
* 4 transition sites (Tab handler, ensure_visual_height_index, reload,
poll VHI complete) now clamp cursor_sub_offset against the current
line height instead of blindly zeroing.
Width calculation correctness (root cause of the residual j/k bug):
* gutter_width() pub(crate) method extracted from ui.rs's inline formula,
shared between renderer and App to prevent drift.
* get_content_width() now returns effective text-wrap width (area minus
gutter), matching the renderer's actual_content_width. Previously it
returned raw area width, causing compute_visual_height to under-count
wrap rows for lines that wrapped only after gutter subtraction.
* Free function gutter_width_for(total_lines, is_loading) added so
handle_file_appended and reload_ready_reader can recompute width
after line-count changes without conflicting borrows on self.
* handle_file_appended: width now captured AFTER update_for_append
(was before), so digit-boundary crossings (9->10, 99->100) don't
leave stale gutter widths.
ui.rs render_content now calls app.gutter_width() instead of inlining
the formula (DRY).
Tests: 7 existing tests that locked in the old buggy v_offset = cursor_line
coupling have been rewritten to assert the new correct behavior. New
property tests verify wrap_line_count matches wrap_line_chars across
CJK/emoji/tab/combining-mark/control-char inputs.