diff --git a/build.zig b/build.zig index 8939c8e..ae7e7ab 100644 --- a/build.zig +++ b/build.zig @@ -58,7 +58,7 @@ pub fn build(b: *std.Build) void { build_options.addOption( usize, "expected_package_root_test_count", - if (!provider_enabled and test_filters.len == 0) 1988 else 0, + if (!provider_enabled and test_filters.len == 0) 2000 else 0, ); const build_options_mod = build_options.createModule(); diff --git a/docs/guide.md b/docs/guide.md index 022b189..dabc7b6 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -114,6 +114,34 @@ available for source selections in side-by-side mode and includes the before/after commit IDs and the selected side. Unified diff selections use diff text. Without a selection, `Y` in a diff pane copies the current hunk. +### Wrap long lines + +Press `W` to toggle line wrapping in the Changes, History, or Compare diff +view, or in Repository source text. Diff wrapping works in unified and +side-by-side layouts, including the unified layout used in narrow terminals. +Help (`?`) shows the effective key if you remap `toggle_line_wrap`. + +Wrapping starts off. Each page keeps its own choice while its viewer is +retained, including file changes, reloads, and switching away and back. A +newly created viewer starts with wrapping off; Repository also resets it +when switching repositories. The choice is not saved +across restarts. With wrapping off, long lines are clipped and you can +scroll horizontally with the left/right arrows. + +With wrapping on, continuation rows repeat neither line numbers nor diff +markers. Use the mouse wheel, Page Up/Down, or Ctrl+u/d/b/f to read long +wrapped lines. `j` / `k` still move between original source rows. Diff search +moves by original row and reveals its first fragment; scroll down to read a +match farther along that row. Repository search retains each matching +occurrence, including multiple matches on one line, and reveals the matching +fragment. Selection and copying use the original text, +without inserting newlines at wrap boundaries. + +Tabs retain their spacing across wrap boundaries. If a character cannot +fit even on an empty row, a replacement glyph is shown so scrolling can +continue; copying still preserves the original character. A pane with no +room for text shows no fragments and restores them when widened. + ### Switch repositories and branches Press `R` from any working view to open the repository picker. Select a diff --git a/src/app/diff_surface.zig b/src/app/diff_surface.zig index e972072..76d23bf 100644 --- a/src/app/diff_surface.zig +++ b/src/app/diff_surface.zig @@ -55,10 +55,43 @@ pub const SelectionCompletionPolicy = enum { pub const ViewOptions = struct { line_numbers: bool = true, + line_wrap: bool = false, pub fn toggleLineNumbers(self: *ViewOptions) void { self.line_numbers = !self.line_numbers; } + + pub fn toggleLineWrap(self: *ViewOptions) void { + self.line_wrap = !self.line_wrap; + } +}; + +/// One position: OFF and ordinary rows have no independently mutable wrap state. +pub const ViewportPosition = union(enum) { + logical: usize, + wrapped: struct { + row: usize, + target: ?context.SelectedTarget, + content: ContentToken, + anchor: WrapAnchor, + }, + + pub fn atRow(value: usize) ViewportPosition { + return .{ .logical = value }; + } + + pub fn row(self: ViewportPosition) usize { + return switch (self) { + .logical => |value| value, + .wrapped => |value| value.row, + }; + } +}; + +pub const WrapAnchor = struct { + side: ?diff_selection.Side = null, + byte: usize = 0, + tab_cell: usize = 0, }; pub const ViewerState = struct { @@ -73,7 +106,7 @@ pub const ViewerState = struct { sidebar_hidden: bool = false, sidebar_width: ?u16 = null, sidebar_horizontal_scroll: usize = 0, - diff_scroll: usize = 0, + diff_scroll: ViewportPosition = .{ .logical = 0 }, diff_horizontal_scroll: usize = 0, diff_cursor: diff_view_model.BodyCoordinate = .{ .metadata = 0 }, display_mode: diff_render.DisplayMode = .side_by_side, @@ -100,8 +133,8 @@ pub const ReloadAnchor = struct { visible_sidebar_row: usize, diff_cursor: diff_view_model.BodyCoordinate, diff_cursor_offset: ?usize, - diff_scroll: usize, - selection_viewport: ?selection_action.SelectionViewportAnchor = null, + diff_scroll: ViewportPosition, + selection_viewport: ?navigation.ViewportAnchor = null, diff_horizontal_scroll: usize, sidebar_horizontal_scroll: usize, search_coordinate: ?diff_view_model.BodyCoordinate, @@ -151,7 +184,7 @@ pub const DisplayNavigationSnapshot = struct { selected_target: ?context.SelectedTarget, selected_node: usize, diff_cursor: diff_view_model.BodyCoordinate, - diff_scroll: usize, + diff_scroll: ViewportPosition, diff_horizontal_scroll: usize, sidebar_horizontal_scroll: usize, search_coordinate: ?diff_view_model.BodyCoordinate, diff --git a/src/app/diff_surface/body_resolver.zig b/src/app/diff_surface/body_resolver.zig index 884b751..4d08083 100644 --- a/src/app/diff_surface/body_resolver.zig +++ b/src/app/diff_surface/body_resolver.zig @@ -99,9 +99,11 @@ pub const RenderProjectedBodyArgs = struct { requested_mode: diff_render.DisplayMode, display_mode_toggle_key: ?[]const u8 = null, scroll: usize, + wrap_start: usize = 0, horizontal_scroll: usize, pane_active: bool, line_numbers: bool, + line_wrap: bool = false, highlighted_hunk: ?usize, cursor_offset: ?usize, palette: theme.Palette, @@ -414,6 +416,7 @@ test "body resolver forwards render arguments and propagates renderer errors" { .horizontal_scroll = 11, .pane_active = false, .line_numbers = false, + .line_wrap = false, .highlighted_hunk = 13, .cursor_offset = 17, .palette = .default(), diff --git a/src/app/diff_surface/message.zig b/src/app/diff_surface/message.zig index 0eb692a..767bfd6 100644 --- a/src/app/diff_surface/message.zig +++ b/src/app/diff_surface/message.zig @@ -84,4 +84,5 @@ pub const Msg = union(enum) { increase_sidebar_width, toggle_display_mode, toggle_line_numbers, + toggle_line_wrap, }; diff --git a/src/app/diff_surface/navigation.zig b/src/app/diff_surface/navigation.zig index bf46561..cc10922 100644 --- a/src/app/diff_surface/navigation.zig +++ b/src/app/diff_surface/navigation.zig @@ -23,6 +23,7 @@ const file_tree = @import("../../file_tree.zig"); const loaded_diff = @import("../../loaded_diff.zig"); const sidebar_view_model = @import("../../sidebar/view_model.zig"); const text_projection = @import("chasen_ui").text_projection; +const line_wrap = @import("../../line_wrap.zig"); const selection_action = @import("selection_action.zig"); const LoadedDiff = loaded_diff.LoadedDiff; @@ -31,6 +32,19 @@ const SizeDirection = app_direction.Size; const VerticalDirection = app_direction.Vertical; const review_tab_width: usize = 4; +const VisualPosition = struct { row: usize, fragment: usize = 0 }; + +/// Temporary value-only source anchor. The existing selection mapping remains +/// the fallback; only the viewport position carries an intra-row source token. +pub const ViewportAnchor = struct { + semantic_source: selection_action.SemanticSource, + source_offset_fallback: usize, + raw_presentation_scroll: usize, + basis: selection_action.ViewportBasis, + position: diff_surface.ViewportPosition = .{ .logical = 0 }, + target: ?context.SelectedTarget = null, +}; + pub const ParsedSelectionTarget = diff_surface.body_resolver.ParsedSelectionTarget; pub fn resolvedTargetAllowsHunkFold(target: diff_surface.ResolvedTarget) bool { @@ -59,11 +73,37 @@ pub const ParsedMouseLine = struct { /// painter. Page adapters may interpret tokens, but shared diff geometry stays /// authoritative. pub const PresentationCellHit = union(enum) { - source: struct { source_offset: usize, local_col: u16 }, + source: struct { source_offset: usize, local_col: u16, wrap_row: usize = 0 }, card: struct { token: usize, local_row: usize, local_col: u16 }, spacer: struct { local_col: u16 }, }; +const WrapLayout = struct { + visible_rows: usize, + target_screen_row: ?usize, + used_rows: usize = 0, + hit: ?struct { + offset: usize, + wrap_row: usize, + } = null, + first_fragment: usize = 0, + + /// Returns false once the pane is full. + fn add(self: *WrapLayout, offset: usize, height: usize) bool { + const skip = if (self.used_rows == 0) self.first_fragment else 0; + const visible_height = height -| skip; + if (self.target_screen_row) |target| { + if (target >= self.used_rows and target - self.used_rows < visible_height) { + self.hit = .{ .offset = offset, .wrap_row = target - self.used_rows + skip }; + } + } + + self.used_rows +|= visible_height; + + return self.used_rows < self.visible_rows; + } +}; + pub const KeyboardLineHit = struct { source_offset: usize, hit: diff_surface.DiffMouseHit, @@ -107,29 +147,41 @@ pub const ResidualSelectionOwner = struct { /// Allocator-bound transaction used by every in-place change to the source-row /// mapping. The viewport is captured while the old mapping is still live; all -/// selection owners are retired before mutation; completion advances exactly +/// live borrows are retired before mutation; completion advances exactly /// one layout revision and restores the semantic viewport in the new mapping. pub const SelectionMappingCleanup = struct { allocator: std.mem.Allocator, residual_owner: ?ResidualSelectionOwner = null, pub const Prepared = struct { - viewport: ?selection_action.SelectionViewportAnchor, + viewport: ?ViewportAnchor, + retained_token: ?diff_surface.ContentToken = null, }; pub fn prepare(self: SelectionMappingCleanup, body: BodyController) Prepared { const viewport = body.captureSelectionViewportAnchor(); + const retained_token = if (body.view().lineWrapActive() and body.view().retainedSelectionPresentation() != null) body.view().currentContentToken() else null; body.controller.clearDiffSelection(); - if (body.controller.surface.completed_selection.*) |*completed| { + if (retained_token == null) if (body.controller.surface.completed_selection.*) |*completed| { completed.deinit(self.allocator); body.controller.surface.completed_selection.* = null; - } - if (self.residual_owner) |owner| owner.clear(); - return .{ .viewport = viewport }; + }; + if (retained_token == null) if (self.residual_owner) |owner| owner.clear(); + return .{ .viewport = viewport, .retained_token = retained_token }; } - pub fn complete(_: SelectionMappingCleanup, body: BodyController, prepared: Prepared) void { + pub fn complete(self: SelectionMappingCleanup, body: BodyController, prepared: Prepared) void { body.controller.advanceSelectionLayoutRevision(); + if (prepared.retained_token) |old_token| if (body.controller.surface.completed_selection.*) |*completed| { + const current = body.view().currentContentToken(); + if (current != null and old_token.eql(current.?) and prepared.viewport != null and std.meta.eql(prepared.viewport.?.target, body.controller.surface.viewer.selected_target)) { + completed.selection_layout_revision = body.controller.surface.selection_layout_revision.*; + } else { + completed.deinit(self.allocator); + body.controller.surface.completed_selection.* = null; + if (self.residual_owner) |owner| owner.clear(); + } + }; if (prepared.viewport) |anchor| body.restoreSelectionViewportAnchor(anchor); } }; @@ -398,7 +450,7 @@ pub const BodyView = struct { parsed.range.end.line_index >= target.file.hunks[parsed.range.end.hunk_index].lines.len) break :blk null; break :blk switch (parsed.content) { - .source_side => |source| if (self.view.effectiveDisplayMode() != .side_by_side) null else .{ + .source_side => |source| if (!self.lineWrapActive() and self.view.effectiveDisplayMode() != .side_by_side) null else .{ .view = .{ .identity = target.identity, .content = .{ .source_side = .{ .side = source.side, .mode = source.mode } }, @@ -407,7 +459,7 @@ pub const BodyView = struct { }, .line_count = source.fragments.line_count, }, - .unified_diff => |unified| if (self.view.effectiveDisplayMode() != .unified) null else .{ + .unified_diff => |unified| if (!self.lineWrapActive() and self.view.effectiveDisplayMode() != .unified) null else .{ .view = .{ .identity = target.identity, .content = .unified_diff, @@ -426,7 +478,7 @@ pub const BodyView = struct { const source_rows = body.source.rowCount(); if (generated.range.start.line_index >= source_rows or generated.range.end.line_index >= source_rows) break :blk null; break :blk switch (generated.content) { - .source_side => |source| if (self.view.effectiveDisplayMode() != .side_by_side) null else .{ + .source_side => |source| if (!self.lineWrapActive() and self.view.effectiveDisplayMode() != .side_by_side) null else .{ .view = .{ .identity = .{ .generated_file = .{ .path_key = body.path } }, .content = .{ .source_side = .{ .side = .new, .mode = source.mode } }, @@ -435,7 +487,7 @@ pub const BodyView = struct { }, .line_count = source.fragment.line_count, }, - .unified_diff => |unified| if (self.view.effectiveDisplayMode() != .unified) null else .{ + .unified_diff => |unified| if (!self.lineWrapActive() and self.view.effectiveDisplayMode() != .unified) null else .{ .view = .{ .identity = .{ .generated_file = .{ .path_key = body.path } }, .content = .unified_diff, @@ -522,7 +574,189 @@ pub const BodyView = struct { } pub fn renderDiffScroll(self: BodyView) usize { - return self.view.surface.viewer.diff_scroll; + return self.view.surface.viewer.diff_scroll.row(); + } + + pub fn renderWrapStart(self: BodyView) usize { + return if (self.lineWrapActive()) self.visualPosition().fragment else 0; + } + + const Row = struct { + body: diff_view_model.BodyRow, + old: ?diff_view_model.BodyCoordinate = null, + new: ?diff_view_model.BodyCoordinate = null, + }; + + fn rowAt(self: BodyView, offset: usize) ?Row { + const mode = self.view.effectiveDisplayMode(); + if (self.generatedBody()) |body| { + const text = body.source.lineBody(offset) orelse return null; + return .{ .body = diff_render.generatedBodyRow(.{ .kind = .added, .text = text }, mode) }; + } + const file = self.displayedDiffFile() orelse return null; + const index = self.displayedDiffLineIndex(mode); + var rows = if (index) |value| + diff_view_model.BodyRowIterator.initAtWithFolded(file, mode, value, offset, self.selectedFoldedHunks()) + else + diff_view_model.BodyRowIterator.initWithFolded(file, mode, self.selectedFoldedHunks()); + if (index == null) { + for (0..offset) |_| _ = rows.next() orelse return null; + } + const body = rows.next() orelse return null; + var result: Row = .{ .body = body }; + const hunk = rows.currentHunkIndex() orelse return result; + if (rows.currentUnifiedLineIndex()) |line| { + const coordinate: diff_view_model.BodyCoordinate = .{ .hunk_line = .{ .hunk_index = hunk, .line_index = line } }; + if (body.unified_line.kind != .added) result.old = coordinate; + if (body.unified_line.kind != .removed) result.new = coordinate; + } else if (rows.currentSideBySideRow()) |pair| switch (pair) { + .single => |line| { + const coordinate: diff_view_model.BodyCoordinate = .{ .hunk_line = .{ .hunk_index = hunk, .line_index = line.line_index } }; + if (line.line.kind != .added) result.old = coordinate; + if (line.line.kind != .removed) result.new = coordinate; + }, + .paired => |lines| { + if (lines.removed) |line| result.old = .{ .hunk_line = .{ .hunk_index = hunk, .line_index = line.line_index } }; + if (lines.added) |line| result.new = .{ .hunk_line = .{ .hunk_index = hunk, .line_index = line.line_index } }; + }, + }; + return result; + } + + fn fragments(self: BodyView, row: Row, side: diff_selection.Side) ?line_wrap.Iterator { + const text = switch (row.body) { + .unified_line => |line| if ((side == .old and line.kind != .added) or (side == .new and line.kind != .removed)) line.text else return null, + .side_by_side => |sides| switch (sides) { + .single => |line| if ((side == .old and line.kind != .added) or (side == .new and line.kind != .removed)) line.text else return null, + .paired => |pair| if (if (side == .old) pair.removed else pair.added) |line| line.text else return null, + }, + else => return null, + }; + const width = diff_render.bodyWidth(self.view.diffPaneWidth()); + const mode = self.view.effectiveDisplayMode(); + const panes = diff_render.sideBySideGeometry(width); + const pane_width = if (mode == .unified) width else if (side == .old) panes.old.width else panes.new.width; + const projection = text_projection.Projection.init(text, .{ .tab_width = review_tab_width }) catch return null; + return line_wrap.Iterator.init(projection, pane_width -| diff_render.lineTextStart(self.view.surface.viewer.view_options.line_numbers, if (mode == .unified) .unified else .side_by_side)); + } + + fn fragmentAt(self: BodyView, row: Row, side: diff_selection.Side, ordinal: usize) ?line_wrap.Fragment { + var iterator = self.fragments(row, side) orelse return null; + for (0..ordinal) |_| _ = iterator.next() orelse return null; + return iterator.next(); + } + + fn rowHeight(self: BodyView, offset: usize) usize { + const row = self.rowAt(offset) orelse return 1; + return diff_render.wrappedRowHeight(row.body, diff_render.bodyWidth(self.view.diffPaneWidth()), self.view.surface.viewer.view_options.line_numbers); + } + + fn fragmentForAnchor(self: BodyView, row: Row, anchor: diff_surface.WrapAnchor) usize { + const side = anchor.side orelse return 0; + var iterator = self.fragments(row, side) orelse return 0; + if (iterator.width == 0) return 0; + var tokens = iterator.tokens; + var cell: usize = 0; + while (tokens.next()) |token| { + cell = token.cell_end; + if (token.byte_end > anchor.byte) { + cell = token.cell_start + @min(anchor.tab_cell, token.cell_end - token.cell_start); + break; + } + } + var ordinal: usize = 0; + var last: usize = 0; + while (iterator.next()) |fragment| : (ordinal += 1) { + last = ordinal; + if (cell < fragment.cells.end) return ordinal; + } + return last; + } + + fn boundWrapAnchor(self: BodyView) ?diff_surface.WrapAnchor { + const position = self.view.surface.viewer.diff_scroll; + if (position != .wrapped) return null; + const token = self.currentContentToken() orelse return null; + if (!std.meta.eql(position.wrapped.target, self.view.surface.viewer.selected_target) or !position.wrapped.content.eql(token)) return null; + return position.wrapped.anchor; + } + + fn visualPosition(self: BodyView) VisualPosition { + const row = @min(self.view.surface.viewer.diff_scroll.row(), self.sourceDiffLineCount() -| 1); + const anchor = self.boundWrapAnchor() orelse return .{ .row = row }; + return .{ .row = row, .fragment = self.fragmentForAnchor(self.rowAt(row) orelse return .{ .row = row }, anchor) }; + } + + fn positionAt(self: BodyView, position: VisualPosition) diff_surface.ViewportPosition { + if (!self.lineWrapActive()) return .atRow(position.row); + const token = self.currentContentToken() orelse return .atRow(position.row); + const row = self.rowAt(position.row) orelse return .atRow(position.row); + var anchor: diff_surface.WrapAnchor = .{}; + const side: diff_selection.Side = if (self.fragmentAt(row, .new, position.fragment) != null) .new else .old; + if (self.fragmentAt(row, side, position.fragment)) |fragment| { + anchor.side = side; + anchor.byte = fragment.bytes.start; + var tokens = fragment.tokens; + if (tokens.next()) |first| { + anchor.byte = first.byte_start; + anchor.tab_cell = fragment.cells.start -| first.cell_start; + } + } + return .{ .wrapped = .{ .row = position.row, .target = self.view.surface.viewer.selected_target, .content = token, .anchor = anchor } }; + } + + fn stepVisual(self: BodyView, start: VisualPosition, direction: VerticalDirection, count: usize) VisualPosition { + var position = start; + var remaining = count; + const rows = self.sourceDiffLineCount(); + if (rows == 0) return .{ .row = 0 }; + while (remaining > 0) switch (direction) { + .up => { + const step = @min(remaining, position.fragment); + position.fragment -= step; + remaining -= step; + if (remaining == 0 or position.row == 0) break; + position.row -= 1; + position.fragment = self.rowHeight(position.row) - 1; + remaining -= 1; + }, + .down => { + const last = self.rowHeight(position.row) - 1; + const step = @min(remaining, last -| position.fragment); + position.fragment += step; + remaining -= step; + if (remaining == 0 or position.row + 1 >= rows) break; + position.row += 1; + position.fragment = 0; + remaining -= 1; + }, + }; + return position; + } + + fn clampVisual(self: BodyView, position: VisualPosition) VisualPosition { + const last = self.sourceDiffLineCount() -| 1; + const maximum = self.stepVisual(.{ .row = last, .fragment = self.rowHeight(last) - 1 }, .up, self.view.diffVisibleRows() -| 1); + if (position.row > maximum.row or (position.row == maximum.row and position.fragment > maximum.fragment)) return maximum; + return position; + } + + /// First visible fragment of a logical row. Passive markers never scroll. + pub fn screenRowForOffset(self: BodyView, offset: usize) ?usize { + if (!self.lineWrapActive()) { + const scroll = self.renderDiffScroll(); + if (offset < scroll or offset - scroll >= self.view.diffVisibleRows()) return null; + return offset - scroll; + } + const start = self.visualPosition(); + if (offset < start.row) return null; + var screen: usize = 0; + var row = start.row; + while (row <= offset and screen < self.view.diffVisibleRows()) : (row += 1) { + if (row == offset) return screen; + screen +|= self.rowHeight(row) -| if (row == start.row) start.fragment else @as(usize, 0); + } + return null; } pub fn renderDiffCursorOffset(self: BodyView) ?usize { @@ -610,8 +844,20 @@ pub const BodyView = struct { const visible_body_row: usize = point.row - diff_render.body_start_row; if (visible_body_row >= self.view.diffVisibleRows()) return null; - const presentation_offset = self.view.surface.viewer.diff_scroll + visible_body_row; const source_rows = self.sourceDiffLineCount(); + + if (self.lineWrapActive()) { + const hit = self.wrapLayoutFrom(self.view.surface.viewer.diff_scroll.row(), visible_body_row).hit orelse return null; + return .{ + .source = .{ + .source_offset = hit.offset, + .local_col = local_col, + .wrap_row = hit.wrap_row, + }, + }; + } + + const presentation_offset = self.view.surface.viewer.diff_scroll.row() + visible_body_row; if (self.view.presentation_rows) |rows| { if (rows.source_rows != source_rows) return null; const mode = self.view.effectiveDisplayMode(); @@ -879,10 +1125,7 @@ pub const BodyView = struct { }; const region = hit.region orelse return null; const model_mode = if (locked) |selection_value| selection_value.mode() else region.mode; - const point_value = if (region.leading_boundary) - diff_selection.pointFromBoundary(hit.hunk_index, hit.line_index, 0) - else - pointForTextCell(hit.hunk_index, hit.line_index, hit.line.text, model_mode, self.view.surface.viewer.diff_horizontal_scroll, region.text_cell) orelse return null; + const point_value = self.pointForBodyText(hit.hunk_index, hit.line_index, hit.line.text, model_mode, region, body_width, source.wrap_row) orelse return null; return .{ .identity = target.identity, .content = .{ .source_side = .{ .side = region.side, .mode = model_mode } }, @@ -905,10 +1148,7 @@ pub const BodyView = struct { return .{ .identity = .{ .generated_file = .{ .path_key = generated.path } }, .content = .{ .source_side = .{ .side = .new, .mode = model_mode } }, - .point = if (region.leading_boundary) - diff_selection.pointFromBoundary(0, offset, 0) - else - pointForTextCell(0, offset, line, model_mode, self.view.surface.viewer.diff_horizontal_scroll, region.text_cell) orelse return null, + .point = self.pointForBodyText(0, offset, line, model_mode, region, body_width, source.wrap_row) orelse return null, }; } @@ -917,7 +1157,101 @@ pub const BodyView = struct { return generated.source.rowCount(); } + pub fn lineWrapActive(self: BodyView) bool { + return self.view.surface.viewer.view_options.line_wrap and self.view.presentation_rows == null; + } + + pub fn diffVisibleLogicalRows(self: BodyView) usize { + const scroll = self.renderDiffScroll(); + const visible_rows = self.view.diffVisibleRows(); + if (visible_rows == 0 or !self.lineWrapActive()) return visible_rows; + const last = self.wrapLayoutFrom(scroll, visible_rows - 1).hit; + return if (last) |hit| hit.offset - scroll + 1 else self.sourceDiffLineCount() -| scroll; + } + + fn pointForBodyText(self: BodyView, hunk_index: usize, line_index: usize, text: []const u8, mode: diff_selection.Mode, region: SelectionRegion, body_width: u16, wrap_row: usize) ?diff_selection.Point { + if (!self.lineWrapActive()) return if (region.leading_boundary) + diff_selection.pointFromBoundary(hunk_index, line_index, 0) + else + pointForTextCell(hunk_index, line_index, text, mode, self.view.surface.viewer.diff_horizontal_scroll, region.text_cell); + + const geometry = diff_render.sideBySideGeometry(body_width); + const pane_width = switch (region.side) { + .old => geometry.old.width, + .new => geometry.new.width, + }; + const text_col = diff_render.lineTextStart(self.view.surface.viewer.view_options.line_numbers, .side_by_side); + const projection = text_projection.Projection.init(text, .{ .tab_width = review_tab_width }) catch return null; + var wrapped = line_wrap.Iterator.init(projection, visibleTextWidth(pane_width, text_col)); + for (0..wrap_row) |_| _ = wrapped.next() orelse return null; + const fragment = wrapped.next() orelse return null; + if (fragment.placeholder) return null; + if (mode == .line) return diff_selection.pointFromLine(hunk_index, line_index); + if (region.leading_boundary) return diff_selection.pointFromBoundary(hunk_index, line_index, fragment.bytes.start); + return switch (fragment.hitCell(region.text_cell) orelse return null) { + .token => |token| diff_selection.pointFromToken(hunk_index, line_index, token), + .boundary => |boundary| diff_selection.pointFromBoundary(hunk_index, line_index, boundary.byte_offset), + }; + } + + fn wrapLayoutFrom(self: BodyView, scroll: usize, target_screen_row: ?usize) WrapLayout { + const mode = self.view.effectiveDisplayMode(); + const body_width = diff_render.bodyWidth(self.view.diffPaneWidth()); + const line_numbers = self.view.surface.viewer.view_options.line_numbers; + + var layout_value: WrapLayout = .{ + .visible_rows = self.view.diffVisibleRows(), + .target_screen_row = target_screen_row, + .first_fragment = if (scroll == self.renderDiffScroll()) self.renderWrapStart() else 0, + }; + + if (self.generatedBody()) |body| { + var row_index = scroll; + + while (row_index < body.source.rowCount()) : (row_index += 1) { + const line: diff_parser.DiffLine = .{ .kind = .added, .text = body.source.lineBody(row_index) orelse break }; + + if (!layout_value.add(row_index, diff_render.wrappedRowHeight(diff_render.generatedBodyRow(line, mode), body_width, line_numbers))) { + break; + } + } + + return layout_value; + } + + const file = self.displayedDiffFile() orelse return layout_value; + const line_index = self.displayedDiffLineIndex(mode); + + var rows = + if (line_index) |index| + diff_view_model.BodyRowIterator.initAtWithFolded(file, mode, index, scroll, self.selectedFoldedHunks()) + else + diff_view_model.BodyRowIterator.initWithFolded(file, mode, self.selectedFoldedHunks()); + + var offset: usize = + if (line_index != null) + scroll + else + 0; + + while (rows.next()) |body_row| : (offset += 1) { + if (offset < scroll) { + continue; + } + + if (!layout_value.add(offset, diff_render.wrappedRowHeight(body_row, body_width, line_numbers))) { + break; + } + } + + return layout_value; + } + pub fn visibleBodyTextMaxHorizontalScroll(self: BodyView) usize { + if (self.lineWrapActive()) { + return 0; + } + const mode = self.view.effectiveDisplayMode(); const visible_rows = self.view.diffVisibleRows(); if (visible_rows == 0) return 0; @@ -1134,23 +1468,37 @@ pub const BodyView = struct { }; } - pub fn captureSelectionViewportAnchor(self: BodyView) ?selection_action.SelectionViewportAnchor { - if (self.selectionPresentation() == null) return null; + pub fn captureSelectionViewportAnchor(self: BodyView) ?ViewportAnchor { + if (!self.lineWrapActive() and self.selectionPresentation() == null) return null; const basis = self.selectionViewportBasis(); if (basis.source_rows == 0) return null; - const source_anchor = self.sourceAnchorAtOrBeforePresentation(self.view.surface.viewer.diff_scroll) orelse return null; + const source_anchor = self.sourceAnchorAtOrBeforePresentation(self.view.surface.viewer.diff_scroll.row()) orelse return null; const position = selection_action.captureAnchorPosition(basis.source_rows, source_anchor); + const viewport = if (self.lineWrapActive()) + if (self.boundWrapAnchor() != null) self.view.surface.viewer.diff_scroll else self.positionAt(self.visualPosition()) + else + self.view.surface.viewer.diff_scroll; + var semantic = self.semanticSourceAtOffset(position.source_offset_fallback); + if (viewport == .wrapped) { + if (self.rowAt(position.source_offset_fallback)) |row| { + if (viewport.wrapped.anchor.side) |side| { + if (if (side == .old) row.old else row.new) |coordinate| semantic = .{ .parsed = coordinate }; + } + } + } return .{ - .semantic_source = self.semanticSourceAtOffset(position.source_offset_fallback), + .semantic_source = semantic, .source_offset_fallback = position.source_offset_fallback, - .raw_presentation_scroll = self.view.surface.viewer.diff_scroll, + .raw_presentation_scroll = self.view.surface.viewer.diff_scroll.row(), .basis = basis, + .position = viewport, + .target = self.view.surface.viewer.selected_target, }; } - pub fn restoreSelectionViewportAnchor(self: BodyView, anchor: selection_action.SelectionViewportAnchor) usize { + pub fn restoreSelectionViewportAnchor(self: BodyView, anchor: ViewportAnchor) usize { return selection_action.restoreViewportAnchor( - anchor, + .{ .semantic_source = anchor.semantic_source, .source_offset_fallback = anchor.source_offset_fallback, .raw_presentation_scroll = anchor.raw_presentation_scroll, .basis = anchor.basis }, self.selectionViewportBasis(), if (self.resolveSemanticSource(anchor.semantic_source)) |source_offset| self.sourceToPresentationOffset(source_offset) @@ -1161,11 +1509,32 @@ pub const BodyView = struct { ); } + pub fn restoredViewport(self: BodyView, anchor: ViewportAnchor, rebind_target: bool) diff_surface.ViewportPosition { + if (!self.lineWrapActive()) return .atRow(self.restoreSelectionViewportAnchor(anchor)); + if (!rebind_target and !std.meta.eql(anchor.target, self.view.surface.viewer.selected_target)) return .atRow(0); + const row = self.resolveSemanticSource(anchor.semantic_source) orelse @min(anchor.source_offset_fallback, self.sourceDiffLineCount() -| 1); + if (anchor.position == .wrapped) { + const content = self.currentContentToken() orelse return .atRow(row); + if (anchor.position.wrapped.content.eql(content)) { + var saved = anchor.position.wrapped; + saved.row = row; + saved.target = self.view.surface.viewer.selected_target; + // Folded/non-text anchors have no source token. + if (self.rowAt(row)) |body| if (saved.anchor.side) |side| { + if (self.fragments(body, side) == null) saved.anchor = .{}; + }; + return .{ .wrapped = saved }; + } + } + return .atRow(row); + } + pub fn visibleDiffCursorOffset(self: BodyView) ?usize { const offset = self.selectedDiffCursorPresentationOffset() orelse return null; - const visible_rows = self.view.diffVisibleRows(); - if (offset < self.view.surface.viewer.diff_scroll) return null; - if (visible_rows == 0 or offset >= self.view.surface.viewer.diff_scroll + visible_rows) return null; + if (self.lineWrapActive()) return if (self.screenRowForOffset(offset) != null) offset else null; + const visible_rows = self.diffVisibleLogicalRows(); + if (offset < self.view.surface.viewer.diff_scroll.row()) return null; + if (visible_rows == 0 or offset >= self.view.surface.viewer.diff_scroll.row() + visible_rows) return null; return offset; } @@ -1246,7 +1615,7 @@ pub const Controller = struct { self.surface.completed_selection.* = null; if (anchor) |value| { const incoming = (BodyController{ .controller = self, .resolver = resolver }).view(); - self.surface.viewer.diff_scroll = incoming.restoreSelectionViewportAnchor(value); + self.surface.viewer.diff_scroll = incoming.restoredViewport(value, false); } } @@ -1372,12 +1741,15 @@ pub const Controller = struct { } pub fn scrollSearchMatchIntoView(self: Controller) void { + // This resolver-free legacy entry is used during passive shell reflow. + // Wrapped navigation is reconciled by BodyController, never by a marker. + if (self.surface.viewer.view_options.line_wrap) return; const offset = self.surface.search.match_offset orelse return; const visible_rows = self.view().diffVisibleRows(); - if (offset < self.surface.viewer.diff_scroll) { - self.surface.viewer.diff_scroll = offset; - } else if (visible_rows > 0 and offset >= self.surface.viewer.diff_scroll + visible_rows) { - self.surface.viewer.diff_scroll = offset + 1 - visible_rows; + if (offset < self.surface.viewer.diff_scroll.row()) { + self.surface.viewer.diff_scroll = .{ .logical = offset }; + } else if (visible_rows > 0 and offset >= self.surface.viewer.diff_scroll.row() + visible_rows) { + self.surface.viewer.diff_scroll = .{ .logical = offset + 1 - visible_rows }; } } @@ -1463,22 +1835,24 @@ pub const BodyController = struct { } pub fn scrollSearchMatchIntoView(self: BodyController) void { + if (self.view().lineWrapActive()) return; const source_offset = self.controller.surface.search.match_offset orelse return; const offset = self.view().sourceToPresentationOffset(source_offset) orelse return; - const visible_rows = self.controller.view().diffVisibleRows(); - if (offset < self.controller.surface.viewer.diff_scroll) { - self.controller.surface.viewer.diff_scroll = offset; - } else if (visible_rows > 0 and offset >= self.controller.surface.viewer.diff_scroll + visible_rows) { - self.controller.surface.viewer.diff_scroll = offset + 1 - visible_rows; + const visible_rows = self.view().diffVisibleLogicalRows(); + + if (offset < self.controller.surface.viewer.diff_scroll.row()) { + self.controller.surface.viewer.diff_scroll = .{ .logical = offset }; + } else if (visible_rows > 0 and offset >= self.controller.surface.viewer.diff_scroll.row() + visible_rows) { + self.controller.surface.viewer.diff_scroll = .{ .logical = offset + 1 - visible_rows }; } } - pub fn captureSelectionViewportAnchor(self: BodyController) ?selection_action.SelectionViewportAnchor { + pub fn captureSelectionViewportAnchor(self: BodyController) ?ViewportAnchor { return self.view().captureSelectionViewportAnchor(); } - pub fn restoreSelectionViewportAnchor(self: BodyController, anchor: selection_action.SelectionViewportAnchor) void { - self.controller.surface.viewer.diff_scroll = self.view().restoreSelectionViewportAnchor(anchor); + pub fn restoreSelectionViewportAnchor(self: BodyController, anchor: ViewportAnchor) void { + self.controller.surface.viewer.diff_scroll = self.view().restoredViewport(anchor, false); } pub fn clearCompletedSelectionAfterCopy( @@ -1717,9 +2091,9 @@ pub const BodyController = struct { } self.controller.surface.viewer.diff_scroll = if (anchor.selection_viewport) |selection_anchor| - self.view().restoreSelectionViewportAnchor(selection_anchor) + if (selected_same_path) self.view().restoredViewport(selection_anchor, true) else .atRow(anchor.diff_scroll.row()) else - anchor.diff_scroll; + .atRow(anchor.diff_scroll.row()); self.clampDiffNavigation(); self.keepDiffCursorVisible(); self.restoreSearchFromReloadAnchor(cleanup, anchor); @@ -1763,6 +2137,7 @@ pub const BodyController = struct { } pub fn toggleSidebarVisibility(self: BodyController, cleanup: SelectionMappingCleanup) void { + const viewport = self.captureSelectionViewportAnchor(); const previous_width = self.controller.view().diffPaneWidth(); const previous_mode = self.controller.view().effectiveDisplayMode(); var prospective = self.controller.surface.viewer.*; @@ -1774,7 +2149,7 @@ pub const BodyController = struct { self.controller.resetDiffHorizontalScrollIfPaneWidthChanged(previous_width); if (previous_mode != self.controller.view().effectiveDisplayMode()) { cleanup.complete(self, prepared.?); - } + } else if (viewport) |anchor| self.restoreSelectionViewportAnchor(anchor); self.clampDiffNavigationKeepingHunkVisible(); self.updateSearchMatchOffset(); self.scrollSearchMatchIntoView(); @@ -1782,6 +2157,7 @@ pub const BodyController = struct { } pub fn adjustSidebarWidth(self: BodyController, cleanup: SelectionMappingCleanup, direction: SizeDirection) void { + const viewport = self.captureSelectionViewportAnchor(); const total_width = self.controller.surface.layout.width; const previous_width = self.controller.view().diffPaneWidth(); const previous_mode = self.controller.view().effectiveDisplayMode(); @@ -1801,7 +2177,7 @@ pub const BodyController = struct { self.controller.resetDiffHorizontalScrollIfPaneWidthChanged(previous_width); if (previous_mode != self.controller.view().effectiveDisplayMode()) { cleanup.complete(self, prepared.?); - } + } else if (viewport) |anchor| self.restoreSelectionViewportAnchor(anchor); self.clampDiffNavigationKeepingHunkVisible(); self.updateSearchMatchOffset(); self.scrollSearchMatchIntoView(); @@ -1992,7 +2368,7 @@ pub const BodyController = struct { const old_scroll = self.controller.surface.viewer.diff_scroll; self.scrollDiffWithOrigin(if (step.direction == .up) .up else .down, .drag); - if (self.controller.surface.viewer.diff_scroll == old_scroll) return .content_edge; + if (std.meta.eql(self.controller.surface.viewer.diff_scroll, old_scroll)) return .content_edge; const endpoint: diff_surface.MousePoint = .{ .col = step.endpoint.col, @@ -2209,16 +2585,50 @@ pub const BodyController = struct { self.scrollDiffWithOrigin(direction, .pointer); } + fn scrollWrapped(self: BodyController, direction: VerticalDirection, count: usize) void { + const body = self.view(); + const current = body.visualPosition(); + const next = body.clampVisual(body.stepVisual(current, direction, count)); + if (!std.meta.eql(current, next)) self.controller.surface.viewer.diff_scroll = body.positionAt(next); + if (body.diffCursorIsVisible() or body.view.diffVisibleRows() == 0) return; + const cursor = body.selectedDiffCursorOffset() orelse next.row; + const last = body.wrapLayoutFrom(next.row, body.view.diffVisibleRows() - 1).hit; + const target = if (cursor < next.row) next.row else if (last) |hit| hit.offset else body.sourceDiffLineCount() -| 1; + self.controller.surface.viewer.diff_cursor = body.selectedCoordinateAtOffset(target) orelse self.controller.surface.viewer.diff_cursor; + } + + fn placeWrappedCursor(self: BodyController, offset: usize, placement: enum { visible, comfort, center }) void { + const body = self.view(); + const height = body.view.diffVisibleRows(); + if (height == 0) return; + const current = body.visualPosition(); + const band = (cursor_viewport.Bounds{ .content_rows = body.sourceDiffLineCount(), .visible_rows = height }).comfortBand() orelse return; + const screen = body.screenRowForOffset(offset); + const first_visible = offset != current.row or current.fragment == 0; + if (placement == .comfort and first_visible) if (screen) |row| if (band.contains(row)) return; + const desired = switch (placement) { + .center => height / 2, + .visible => if (offset < current.row) @as(usize, 0) else height - 1, + .comfort => if (offset < current.row or (screen != null and screen.? < band.first)) band.first else band.last, + }; + const position = body.clampVisual(body.stepVisual(.{ .row = offset }, .up, desired)); + self.controller.surface.viewer.diff_scroll = body.positionAt(position); + } + fn scrollDiffWithOrigin(self: BodyController, direction: VerticalDirection, origin: enum { pointer, drag }) void { + if (self.view().lineWrapActive()) { + self.scrollWrapped(direction, 1); + return; + } const bounds = self.diffCursorBounds(); - const old_scroll = bounds.clampScroll(self.controller.surface.viewer.diff_scroll); + const old_scroll = bounds.clampScroll(self.controller.surface.viewer.diff_scroll.row()); const old_cursor_offset = self.view().selectedDiffCursorPresentationOffset(); const requested_scroll = switch (direction) { .up => old_scroll -| 1, .down => old_scroll +| 1, }; const new_scroll = bounds.clampScroll(requested_scroll); - self.controller.surface.viewer.diff_scroll = new_scroll; + self.controller.surface.viewer.diff_scroll = .{ .logical = new_scroll }; if (bounds.visible_rows == 0) return; if (old_scroll == new_scroll) { if (origin == .drag or self.controller.surface.selection_owner.activeKeyboardLineSelection()) return; @@ -2236,7 +2646,8 @@ pub const BodyController = struct { } const target = cursor_viewport.retargetCursorAfterViewportScroll( - bounds, + // Wrapped row heights differ at the new scroll. + self.diffCursorBounds(), old_scroll, new_scroll, old_cursor_offset, @@ -2286,12 +2697,14 @@ pub const BodyController = struct { } pub fn moveDiffCursorHalfPage(self: BodyController, direction: VerticalDirection) void { - const step = @max(self.controller.view().diffVisibleRows() / 2, 1); + if (self.view().lineWrapActive()) return self.scrollWrapped(direction, @max(self.view().view.diffVisibleRows() / 2, 1)); + const step = @max(self.view().diffVisibleLogicalRows() / 2, 1); self.moveDiffCursorByDocumentStep(direction, step); } pub fn moveDiffCursorPage(self: BodyController, direction: VerticalDirection) void { - const step = @max(self.controller.view().diffVisibleRows(), 1); + if (self.view().lineWrapActive()) return self.scrollWrapped(direction, @max(self.view().view.diffVisibleRows(), 1)); + const step = @max(self.view().diffVisibleLogicalRows(), 1); self.moveDiffCursorByDocumentStep(direction, step); } @@ -2382,15 +2795,31 @@ pub const BodyController = struct { pub fn clampDiffNavigation(self: BodyController) void { if (self.view().resolvedTarget().kind == .inert) { self.controller.surface.viewer.diff_cursor = .{ .metadata = 0 }; - self.controller.surface.viewer.diff_scroll = 0; + self.controller.surface.viewer.diff_scroll = .{ .logical = 0 }; self.controller.surface.viewer.diff_horizontal_scroll = 0; return; } + if (self.view().lineWrapActive()) { + const body = self.view(); + const current = body.visualPosition(); + // A zero-width placeholder must not replace the saved token when + // bottom clamping temporarily compresses the whole document. + const hidden_text = if (body.boundWrapAnchor()) |anchor| blk: { + const side = anchor.side orelse break :blk false; + const row = body.rowAt(current.row) orelse break :blk false; + const iterator = body.fragments(row, side) orelse break :blk false; + break :blk iterator.width == 0; + } else false; + const clamped = if (hidden_text) current else body.clampVisual(current); + if (body.boundWrapAnchor() == null or !std.meta.eql(current, clamped)) self.controller.surface.viewer.diff_scroll = body.positionAt(clamped); + if (body.selectedDiffCursorOffset() == null) self.initializeDiffCursorForSelectedFile(); + return; + } const bounds = self.diffCursorBounds(); if (bounds.content_rows > 0 and self.view().selectedDiffCursorOffset() == null) { self.initializeDiffCursorForSelectedFile(); } - self.controller.surface.viewer.diff_scroll = bounds.clampScroll(self.controller.surface.viewer.diff_scroll); + self.controller.surface.viewer.diff_scroll = .{ .logical = bounds.clampScroll(self.controller.surface.viewer.diff_scroll.row()) }; } pub fn clampDiffNavigationKeepingHunkVisible(self: BodyController) void { @@ -2399,7 +2828,7 @@ pub const BodyController = struct { } pub fn resetDiffPosition(self: BodyController) void { - self.controller.surface.viewer.diff_scroll = 0; + self.controller.surface.viewer.diff_scroll = .{ .logical = 0 }; self.initializeDiffCursorForSelectedFile(); self.controller.clearSearchMatch(); } @@ -2519,11 +2948,15 @@ pub const BodyController = struct { pub fn keepDiffCursorVisible(self: BodyController) void { const offset = self.view().selectedDiffCursorPresentationOffset() orelse return; - self.controller.surface.viewer.diff_scroll = cursor_viewport.keepCursorVisible( + if (self.view().lineWrapActive()) { + if (self.view().screenRowForOffset(offset) == null) self.placeWrappedCursor(offset, .visible); + return; + } + self.controller.surface.viewer.diff_scroll = .{ .logical = cursor_viewport.keepCursorVisible( self.diffCursorBounds(), - self.controller.surface.viewer.diff_scroll, + self.controller.surface.viewer.diff_scroll.row(), offset, - ); + ) }; } pub fn clampSelection(self: BodyController, file_count: usize) void { @@ -2612,7 +3045,7 @@ pub const BodyController = struct { fn diffCursorBounds(self: BodyController) cursor_viewport.Bounds { return .{ .content_rows = self.view().presentationDiffLineCount(), - .visible_rows = self.controller.view().diffVisibleRows(), + .visible_rows = self.view().diffVisibleLogicalRows(), }; } @@ -2621,11 +3054,12 @@ pub const BodyController = struct { self.clampDiffNavigation(); return; }; - self.controller.surface.viewer.diff_scroll = cursor_viewport.placeCursorInComfortBand( + if (self.view().lineWrapActive()) return self.placeWrappedCursor(cursor_offset, .comfort); + self.controller.surface.viewer.diff_scroll = .{ .logical = cursor_viewport.placeCursorInComfortBand( self.diffCursorBounds(), - self.controller.surface.viewer.diff_scroll, + self.controller.surface.viewer.diff_scroll.row(), cursor_offset, - ); + ) }; } pub fn centerDiffCursor(self: BodyController) void { @@ -2633,11 +3067,12 @@ pub const BodyController = struct { self.clampDiffNavigation(); return; }; - self.controller.surface.viewer.diff_scroll = cursor_viewport.centerCursor( + if (self.view().lineWrapActive()) return self.placeWrappedCursor(cursor_offset, .center); + self.controller.surface.viewer.diff_scroll = .{ .logical = cursor_viewport.centerCursor( self.diffCursorBounds(), - self.controller.surface.viewer.diff_scroll, + self.controller.surface.viewer.diff_scroll.row(), cursor_offset, - ); + ) }; } }; diff --git a/src/app/diff_surface/update.zig b/src/app/diff_surface/update.zig index e37c466..536289c 100644 --- a/src/app/diff_surface/update.zig +++ b/src/app/diff_surface/update.zig @@ -246,7 +246,7 @@ pub const Controller = struct { const old_scroll = self.navigation.view().renderDiffScroll(); self.navigation.controller.surface.viewer.display_mode = self.navigation.controller.surface.viewer.display_mode.toggled(); const new_mode = self.navigation.controller.view().effectiveDisplayMode(); - self.navigation.controller.surface.viewer.diff_scroll = self.navigation.view().remapDiffScrollForModeChange(old_mode, new_mode, old_scroll); + self.navigation.controller.surface.viewer.diff_scroll = .{ .logical = self.navigation.view().remapDiffScrollForModeChange(old_mode, new_mode, old_scroll) }; cleanup.complete(self.navigation, prepared); self.navigation.controller.resetDiffHorizontalScroll(); self.navigation.updateSearchMatchOffset(); @@ -255,8 +255,17 @@ pub const Controller = struct { }, .toggle_line_numbers => { self.navigation.controller.surface.viewer.view_options.toggleLineNumbers(); + if (viewport_anchor) |anchor| self.navigation.restoreSelectionViewportAnchor(anchor); + if (self.navigation.view().lineWrapActive()) self.navigation.clampDiffNavigationKeepingHunkVisible(); self.navigation.clampDiffHorizontalScrollToVisibleRows(); }, + .toggle_line_wrap => { + self.navigation.controller.surface.viewer.view_options.toggleLineWrap(); + const viewer = self.navigation.controller.surface.viewer; + viewer.diff_scroll = .atRow(viewer.diff_scroll.row()); + self.navigation.controller.resetDiffHorizontalScroll(); + self.navigation.keepDiffCursorVisible(); + }, .enter_search => { if (self.navigation.controller.surface.selection_owner.* != .none) { self.navigation.controller.clearDiffSelection(); diff --git a/src/app/diff_surface/view.zig b/src/app/diff_surface/view.zig index 68efcca..ca2a679 100644 --- a/src/app/diff_surface/view.zig +++ b/src/app/diff_surface/view.zig @@ -517,9 +517,11 @@ pub fn viewDiffPane( .requested_mode = state.viewer.display_mode, .display_mode_toggle_key = display_mode_toggle_key, .scroll = body.renderDiffScroll(), + .wrap_start = body.renderWrapStart(), .horizontal_scroll = state.viewer.diff_horizontal_scroll, .pane_active = active, .line_numbers = state.viewer.view_options.line_numbers, + .line_wrap = state.viewer.view_options.line_wrap, .highlighted_hunk = body.selectedHunkIndex(), .cursor_offset = body.renderDiffCursorOffset(), .hunk_stages = try body.hunkStagePresentation(surface.frameAllocator(), file_index), @@ -542,7 +544,7 @@ pub fn viewDiffPane( active, palette, ); - drawSearchMatchMarkerAt(surface, state, palette, if (state.search.match_offset) |offset| body.sourceToPresentationOffset(offset) else null); + drawSearchMatchMarkerRow(surface, palette, if (state.search.match_offset) |offset| if (body.sourceToPresentationOffset(offset)) |logical| body.screenRowForOffset(logical) else null else null); } test "primary diff presentation preserves active selection precedence" { @@ -629,9 +631,11 @@ fn projectedBodyRenderArgs( .requested_mode = state.viewer.display_mode, .display_mode_toggle_key = display_mode_toggle_key, .scroll = body.renderDiffScroll(), + .wrap_start = body.renderWrapStart(), .horizontal_scroll = state.viewer.diff_horizontal_scroll, .pane_active = active, .line_numbers = state.viewer.view_options.line_numbers, + .line_wrap = state.viewer.view_options.line_wrap, .highlighted_hunk = body.selectedHunkIndex(), .cursor_offset = body.renderDiffCursorOffset(), .palette = palette, @@ -949,20 +953,10 @@ pub fn drawPaneHeaderRule(surface: *chasen.Surface, active: bool, palette: theme } } -pub fn drawSearchMatchMarker(surface: *chasen.Surface, state: diff_surface.ReadSurface, palette: theme.Palette) void { - drawSearchMatchMarkerAt(surface, state, palette, state.search.match_offset); -} - -fn drawSearchMatchMarkerAt(surface: *chasen.Surface, state: diff_surface.ReadSurface, palette: theme.Palette, match_offset_opt: ?usize) void { - const match_offset = match_offset_opt orelse return; - if (match_offset < state.viewer.diff_scroll) return; - - const visible_offset = match_offset - state.viewer.diff_scroll; - const body_rows = diff_render.visibleBodyRows(surface.size().height); - if (visible_offset >= body_rows) return; - - const row: u16 = @intCast(layout.diff_body_start_row + visible_offset); - _ = surface.borrowTextAt(0, row, "»", .{ .bold = true, .reverse = true, .fg = palette.color(.prompt) }); +pub fn drawSearchMatchMarkerRow(surface: *chasen.Surface, palette: theme.Palette, visible_offset_opt: ?usize) void { + const visible_offset = visible_offset_opt orelse return; + if (visible_offset >= diff_render.visibleBodyRows(surface.size().height)) return; + _ = surface.borrowTextAt(0, @intCast(layout.diff_body_start_row + visible_offset), "»", .{ .bold = true, .reverse = true, .fg = palette.color(.prompt) }); } pub fn diffContentSurface(surface: *chasen.Surface) chasen.Surface { diff --git a/src/app/page_coordinator_test.zig b/src/app/page_coordinator_test.zig index 2bb2d76..7289543 100644 --- a/src/app/page_coordinator_test.zig +++ b/src/app/page_coordinator_test.zig @@ -183,7 +183,7 @@ test "repository keyboard line selection page exits preserve semantic viewport" const viewport_before = app.pages.repository.captureSelectionViewportAnchor() orelse return error.ExpectedSelectionViewportAnchor; - try std.testing.expectEqual(@as(usize, 5), viewport_before.semantic_source); + try std.testing.expectEqual(@as(usize, 5), viewport_before.line); try requestPageSwitchForTest(&app, &ctx.ctx, target); @@ -194,11 +194,11 @@ test "repository keyboard line selection page exits preserve semantic viewport" if (retain_prior) { const viewport_after = app.pages.repository.captureSelectionViewportAnchor() orelse return error.ExpectedRetainedViewportAnchor; - try std.testing.expectEqual(viewport_before.semantic_source, viewport_after.semantic_source); + try std.testing.expectEqual(viewport_before.line, viewport_after.line); } else { try std.testing.expectEqual( - viewport_before.semantic_source, - app.pages.repository.viewer.source_vertical_scroll, + viewport_before.line, + app.pages.repository.viewer.source_vertical_scroll.line, ); } }; @@ -327,7 +327,7 @@ test "Compare retained selection survives page transitions and clears on reposit // Repository commitment invalidates the complete Compare owner before // page coordination reactivates it. The coordinator must not perform a // second candidate-only clear that could preserve stale scroll state. - app.pages.compare.diff.viewer.diff_scroll = 9; + app.pages.compare.diff.viewer.diff_scroll = .{ .logical = 9 }; app.pages.compare.deinit(allocator); try std.testing.expectEqual( page_coordinator.Intent.compare_refresh, @@ -335,7 +335,7 @@ test "Compare retained selection survives page transitions and clears on reposit ); try std.testing.expect(app.pages.compare.diff.completed_selection == null); try std.testing.expect(app.pages.compare.diff.pinned_selection_basis == null); - try std.testing.expectEqual(@as(usize, 0), app.pages.compare.diff.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), app.pages.compare.diff.viewer.diff_scroll.row()); } test "changes repository transition commit selects exact retained Changes path" { @@ -409,7 +409,7 @@ test "changes repository transition commit maps unchanged and unavailable outcom .viewer = .{ .selected_target = .{ .diff_file = 1 }, .selected_node = 1, - .diff_scroll = 9, + .diff_scroll = .{ .logical = 9 }, }, }, .repository = .{ @@ -431,7 +431,7 @@ test "changes repository transition commit maps unchanged and unavailable outcom try std.testing.expect(!app.pages.repository.active); try std.testing.expectEqual(@as(usize, 1), app.pages.changes.viewer.selected_node); try std.testing.expectEqual(context.SelectedTarget{ .diff_file = 1 }, app.pages.changes.viewer.selected_target.?); - try std.testing.expectEqual(@as(usize, 9), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 9), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqualStrings(case.status, app.status.text()); } } @@ -455,7 +455,7 @@ test "changes repository transition no context dismisses pending and unavailable .viewer = .{ .selected_target = .{ .diff_file = 0 }, .selected_node = 0, - .diff_scroll = 6, + .diff_scroll = .{ .logical = 6 }, }, }, .repository = .{ @@ -488,7 +488,7 @@ test "changes repository transition no context dismisses pending and unavailable try std.testing.expect(!app.pages.repository.active); try std.testing.expect(app.pages.repository.incoming == .none); try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.selected_node); - try std.testing.expectEqual(@as(usize, 6), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 6), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqualStrings("Repository has no resolved file to open in Changes", app.status.text()); } } @@ -853,7 +853,7 @@ fn repositorySelectionViewportStateForTest( .viewer = .{ .focus = .source, .source_cursor = 2, - .source_vertical_scroll = 5, + .source_vertical_scroll = .{ .line = 5 }, }, }; errdefer state.deinit(allocator); diff --git a/src/app/pages/changes.zig b/src/app/pages/changes.zig index 71601b5..0efcaa7 100644 --- a/src/app/pages/changes.zig +++ b/src/app/pages/changes.zig @@ -438,8 +438,8 @@ test "diffSurface adapter exposes shared field pointers without copying" { try std.testing.expectEqual(surface.source, narrowed.source); try std.testing.expectEqual(surface.layout, narrowed.layout); - surface.viewer.diff_scroll = 7; - try std.testing.expectEqual(@as(usize, 7), state.viewer.diff_scroll); + surface.viewer.diff_scroll = .{ .logical = 7 }; + try std.testing.expectEqual(@as(usize, 7), state.viewer.diff_scroll.row()); state.pending_reload = .{ .generation = 1, @@ -451,7 +451,7 @@ test "diffSurface adapter exposes shared field pointers without copying" { .visible_sidebar_row = 0, .diff_cursor = .{ .metadata = 0 }, .diff_cursor_offset = null, - .diff_scroll = 0, + .diff_scroll = .{ .logical = 0 }, .diff_horizontal_scroll = 0, .sidebar_horizontal_scroll = 0, .search_coordinate = null, @@ -516,7 +516,7 @@ test "ChangesPageState initializes reload policy and owns lifecycle cleanup" { .visible_sidebar_row = 3, .diff_cursor = .{ .metadata = 0 }, .diff_cursor_offset = null, - .diff_scroll = 0, + .diff_scroll = .{ .logical = 0 }, .diff_horizontal_scroll = 0, .sidebar_horizontal_scroll = 0, .search_coordinate = null, @@ -533,7 +533,7 @@ test "ChangesPageState initializes reload policy and owns lifecycle cleanup" { .visible_sidebar_row = 4, .diff_cursor = .{ .metadata = 0 }, .diff_cursor_offset = null, - .diff_scroll = 1, + .diff_scroll = .{ .logical = 1 }, .diff_horizontal_scroll = 2, .sidebar_horizontal_scroll = 3, .search_coordinate = null, @@ -545,7 +545,7 @@ test "ChangesPageState initializes reload policy and owns lifecycle cleanup" { .visible_sidebar_row = 5, .diff_cursor = .{ .metadata = 0 }, .diff_cursor_offset = null, - .diff_scroll = 4, + .diff_scroll = .{ .logical = 4 }, .diff_horizontal_scroll = 5, .sidebar_horizontal_scroll = 6, .search_coordinate = null, diff --git a/src/app/pages/changes/body_render.zig b/src/app/pages/changes/body_render.zig index 1d37b48..8c151db 100644 --- a/src/app/pages/changes/body_render.zig +++ b/src/app/pages/changes/body_render.zig @@ -31,9 +31,11 @@ pub fn renderParsed(body: ParsedBody, args: diff_surface.RenderProjectedBodyArgs .requested_mode = args.requested_mode, .display_mode_toggle_key = args.display_mode_toggle_key, .scroll = args.scroll, + .wrap_start = args.wrap_start, .horizontal_scroll = args.horizontal_scroll, .pane_active = args.pane_active, .line_numbers = args.line_numbers, + .line_wrap = args.line_wrap, .highlighted_hunk = args.highlighted_hunk, .cursor_offset = args.cursor_offset, .hunk_stages = body.hunk_stages, @@ -51,9 +53,11 @@ pub fn renderGenerated(bundle: *const changes_projection.GeneratedFileBundle, ar .requested_mode = args.requested_mode, .display_mode_toggle_key = args.display_mode_toggle_key, .scroll = args.scroll, + .wrap_start = args.wrap_start, .horizontal_scroll = args.horizontal_scroll, .pane_active = args.pane_active, .line_numbers = args.line_numbers, + .line_wrap = args.line_wrap, .cursor_offset = args.cursor_offset, .hunk_stages = .all_unstaged, .palette = args.palette, diff --git a/src/app/pages/changes/input.zig b/src/app/pages/changes/input.zig index a46821b..257a258 100644 --- a/src/app/pages/changes/input.zig +++ b/src/app/pages/changes/input.zig @@ -104,6 +104,7 @@ fn publicActionToMsg(action: keymap.PublicAction, diff_focused: bool) ?Msg { .discard => .request_discard_selected_file, .toggle_display_mode => .toggle_display_mode, .toggle_line_numbers => .toggle_line_numbers, + .toggle_line_wrap => .toggle_line_wrap, .toggle_sidebar => .toggle_sidebar_visibility, .decrease_sidebar_width => .decrease_sidebar_width, .increase_sidebar_width => .increase_sidebar_width, diff --git a/src/app/pages/changes/message.zig b/src/app/pages/changes/message.zig index 0ad9749..d21684f 100644 --- a/src/app/pages/changes/message.zig +++ b/src/app/pages/changes/message.zig @@ -87,6 +87,7 @@ pub const Msg = union(enum) { open_selected_file_in_editor, toggle_display_mode, toggle_line_numbers, + toggle_line_wrap, copy_current_line, copy_current_hunk, selection_action: selection_action.Action, @@ -173,6 +174,7 @@ pub const Msg = union(enum) { .increase_sidebar_width, .toggle_display_mode, .toggle_line_numbers, + .toggle_line_wrap, => |payload, tag| @unionInit(shared_message.Msg, @tagName(tag), payload), else => null, }; diff --git a/src/app/pages/changes/navigation.zig b/src/app/pages/changes/navigation.zig index 3ec0b50..82ca516 100644 --- a/src/app/pages/changes/navigation.zig +++ b/src/app/pages/changes/navigation.zig @@ -458,6 +458,16 @@ pub const View = struct { return self.sharedBodyView(&adapter).renderDiffScroll(); } + pub fn renderWrapStart(self: View) usize { + var adapter = self.bodyResolverAdapter(); + return self.sharedBodyView(&adapter).renderWrapStart(); + } + + pub fn screenRowForOffset(self: View, offset: usize) ?usize { + var adapter = self.bodyResolverAdapter(); + return self.sharedBodyView(&adapter).screenRowForOffset(offset); + } + pub fn renderDiffCursorOffset(self: View) ?usize { var adapter = self.bodyResolverAdapter(); return self.sharedBodyView(&adapter).renderDiffCursorOffset(); @@ -468,17 +478,14 @@ pub const View = struct { return self.sharedBodyView(&adapter).retainedSelectionActionAvailable(); } - pub fn captureSelectionViewportAnchor(self: View) ?diff_surface.selection_action.SelectionViewportAnchor { + pub fn captureSelectionViewportAnchor(self: View) ?diff_surface.navigation.ViewportAnchor { var adapter = self.bodyResolverAdapter(); return self.sharedBodyView(&adapter).captureSelectionViewportAnchor(); } - pub fn restoredSelectionViewportScroll( - self: View, - anchor: diff_surface.selection_action.SelectionViewportAnchor, - ) usize { + pub fn restoredViewport(self: View, anchor: diff_surface.navigation.ViewportAnchor) diff_surface.ViewportPosition { var adapter = self.bodyResolverAdapter(); - return self.sharedBodyView(&adapter).restoreSelectionViewportAnchor(anchor); + return self.sharedBodyView(&adapter).restoredViewport(anchor, true); } pub fn selectionActionHit(self: View, point: diff_surface.MousePoint) ?diff_surface.navigation.SelectionActionHit { @@ -1331,12 +1338,13 @@ pub const Controller = struct { return self.sharedController().clearCompletedSelectionAfterCopy(allocator, generation); } - pub fn captureSelectionViewportAnchor(self: Controller) ?diff_surface.selection_action.SelectionViewportAnchor { + pub fn captureSelectionViewportAnchor(self: Controller) ?diff_surface.navigation.ViewportAnchor { return self.view().captureSelectionViewportAnchor(); } - pub fn restoreSelectionViewportAnchor(self: Controller, anchor: diff_surface.selection_action.SelectionViewportAnchor) void { - self.page.viewer.diff_scroll = self.view().restoredSelectionViewportScroll(anchor); + pub fn restoreSelectionViewportAnchor(self: Controller, anchor: diff_surface.navigation.ViewportAnchor) void { + var adapter = self.bodyResolverAdapter(); + self.sharedBodyController(&adapter).restoreSelectionViewportAnchor(anchor); } pub fn selectFileDelta(self: Controller, allocator: std.mem.Allocator, delta: i2) void { @@ -2109,7 +2117,7 @@ const TestHarness = struct { self.pages.changes.changes_projection.deinit(allocator); self.pages.changes.staged_hunks.clear(allocator); } - self.pages.changes.viewer.diff_scroll = 0; + self.pages.changes.viewer.diff_scroll = .{ .logical = 0 }; self.pages.changes.viewer.diff_horizontal_scroll = 0; self.pages.changes.viewer.sidebar_horizontal_scroll = 0; self.pages.changes.viewer.diff_cursor = .{ .metadata = 0 }; @@ -2176,7 +2184,7 @@ fn initProjectionHunkFoldHarness(allocator: std.mem.Allocator) !TestHarness { .viewer = .{ .selected_target = .{ .diff_file = 0 }, .diff_cursor = .{ .hunk_header = 0 }, - .diff_scroll = 2, + .diff_scroll = .{ .logical = 2 }, .diff_horizontal_scroll = 3, }, .search = .{ @@ -2223,7 +2231,7 @@ fn expectProjectionHunkFoldDenied( const unified_hunk_lines_before = active.renderedLineIndex(0, .unified).hunkLineCount(0); const side_by_side_lines_before = active.renderedLineIndex(0, .side_by_side).lineCount(); const cursor_before = harness.pages.changes.viewer.diff_cursor; - const scroll_before = harness.pages.changes.viewer.diff_scroll; + const scroll_before = harness.pages.changes.viewer.diff_scroll.row(); const horizontal_scroll_before = harness.pages.changes.viewer.diff_horizontal_scroll; const search_before = harness.pages.changes.search; @@ -2248,7 +2256,7 @@ fn expectProjectionHunkFoldDenied( try std.testing.expectEqual(unified_hunk_lines_before, active.renderedLineIndex(0, .unified).hunkLineCount(0)); try std.testing.expectEqual(side_by_side_lines_before, active.renderedLineIndex(0, .side_by_side).lineCount()); try std.testing.expectEqualDeep(cursor_before, harness.pages.changes.viewer.diff_cursor); - try std.testing.expectEqual(scroll_before, harness.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(scroll_before, harness.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(horizontal_scroll_before, harness.pages.changes.viewer.diff_horizontal_scroll); try std.testing.expectEqualDeep(search_before, harness.pages.changes.search); try std.testing.expect(harness.pages.changes.completed_selection.?.token.eql(retained_token)); @@ -2267,7 +2275,7 @@ fn expectResolverRenderContains(harness: *TestHarness, needle: []const u8) !void try harness.view().renderProjectedBody(.{ .surface = &ts.surface, .requested_mode = harness.pages.changes.viewer.display_mode, - .scroll = harness.pages.changes.viewer.diff_scroll, + .scroll = harness.pages.changes.viewer.diff_scroll.row(), .horizontal_scroll = harness.pages.changes.viewer.diff_horizontal_scroll, .pane_active = true, .line_numbers = harness.pages.changes.viewer.view_options.line_numbers, @@ -2287,7 +2295,7 @@ fn expectResolverRenderOmits(harness: *TestHarness, needle: []const u8) !void { try harness.view().renderProjectedBody(.{ .surface = &ts.surface, .requested_mode = harness.pages.changes.viewer.display_mode, - .scroll = harness.pages.changes.viewer.diff_scroll, + .scroll = harness.pages.changes.viewer.diff_scroll.row(), .horizontal_scroll = harness.pages.changes.viewer.diff_horizontal_scroll, .pane_active = true, .line_numbers = harness.pages.changes.viewer.view_options.line_numbers, @@ -2413,7 +2421,7 @@ fn retainCombinedHorizontalScrollProjection( fn expectDisplayedBodyHorizontalScrollGeometry(harness: *TestHarness) !void { harness.terminal_size = .{ .width = 140, .height = 16 }; - harness.pages.changes.viewer.diff_scroll = 0; + harness.pages.changes.viewer.diff_scroll = .{ .logical = 0 }; inline for ([_]diff_render.DisplayMode{ .unified, .side_by_side }) |mode| { harness.pages.changes.viewer.display_mode = mode; harness.pages.changes.viewer.view_options.line_numbers = true; @@ -2601,7 +2609,7 @@ test "Changes navigation keeps diff position at file selection boundary" { var harness = TestHarness.init(.{ .load = test_support.loadState(test_support.loadedDiffOne()), .viewer = .{ - .diff_scroll = 4, + .diff_scroll = .{ .logical = 4 }, .diff_cursor = .{ .hunk_header = 1 }, }, }, .{ .width = 100, .height = 8 }); @@ -2609,11 +2617,11 @@ test "Changes navigation keeps diff position at file selection boundary" { harness.controller().selectFileDelta(std.testing.allocator, -1); const loaded = harness.controller().activeLoadedDiff().?; try std.testing.expectEqual(@as(?usize, 0), harness.view().selectedFileIndex(loaded)); - try std.testing.expectEqual(@as(usize, 4), harness.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 4), harness.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(?usize, 1), harness.view().selectedHunkIndex()); harness.controller().selectFileAbsolute(std.testing.allocator, 0); - try std.testing.expectEqual(@as(usize, 4), harness.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 4), harness.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(?usize, 1), harness.view().selectedHunkIndex()); } @@ -2627,7 +2635,7 @@ test "Changes navigation keeps diff cursor visible across mode changes" { }, .{ .width = 100, .height = 9 }); harness.controller().placeDiffCursorInComfortBand(); - try std.testing.expect(harness.pages.changes.viewer.diff_scroll > 0); + try std.testing.expect(harness.pages.changes.viewer.diff_scroll.row() > 0); harness.pages.changes.viewer.display_mode = .side_by_side; harness.controller().clampDiffNavigationKeepingHunkVisible(); @@ -2638,8 +2646,8 @@ test "Changes navigation keeps diff cursor visible across mode changes" { harness.view().selectedHunkIndex().?, ); const visible_rows = harness.view().diffVisibleRows(); - try std.testing.expect(target >= harness.pages.changes.viewer.diff_scroll); - try std.testing.expect(target < harness.pages.changes.viewer.diff_scroll + visible_rows); + try std.testing.expect(target >= harness.pages.changes.viewer.diff_scroll.row()); + try std.testing.expect(target < harness.pages.changes.viewer.diff_scroll.row() + visible_rows); } test "Changes navigation initializes cursor at first rendered body row" { @@ -2651,7 +2659,7 @@ test "Changes navigation initializes cursor at first rendered body row" { try std.testing.expectEqual(@as(?usize, 0), metadata.view().visibleDiffCursorOffset()); const metadata_cursor = metadata.pages.changes.viewer.diff_cursor; metadata.controller().scrollDiff(.down); - try std.testing.expectEqual(@as(usize, 0), metadata.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), metadata.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(metadata_cursor, metadata.pages.changes.viewer.diff_cursor); var binary = TestHarness.init(.{ @@ -2674,7 +2682,7 @@ test "Changes navigation initializes cursor at first rendered body row" { try std.testing.expectEqualDeep(binary_snapshot, binary.view().displayNavigationSnapshot()); } binary.controller().scrollDiff(.down); - try std.testing.expectEqual(@as(usize, 0), binary.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), binary.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(binary_cursor, binary.pages.changes.viewer.diff_cursor); } @@ -2731,14 +2739,14 @@ test "document navigation applies first last half and full page on rendered rows harness.pages.changes.viewer.diff_cursor = harness.view().selectedCoordinateAtOffset(band_last - 1) orelse return error.ExpectedCoordinate; harness.controller().moveDiffCursorRows(.down); - try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(?usize, band_last), harness.view().selectedDiffCursorOffset()); harness.controller().moveDiffCursorRows(.down); - try std.testing.expectEqual(@as(usize, 1), harness.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 1), harness.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(?usize, band_last + 1), harness.view().selectedDiffCursorOffset()); - harness.pages.changes.viewer.diff_scroll = 0; + harness.pages.changes.viewer.diff_scroll = .{ .logical = 0 }; harness.pages.changes.viewer.diff_cursor = harness.view().selectedCoordinateAtOffset(0) orelse return error.ExpectedCoordinate; _ = try applySharedNavigation(&harness, .page_diff_down); @@ -2746,29 +2754,29 @@ test "document navigation applies first last half and full page on rendered rows try std.testing.expectEqual(@as(usize, visible_rows), page_offset); try std.testing.expectEqual( @min(page_offset -| (visible_rows / 2), line_count -| visible_rows), - harness.pages.changes.viewer.diff_scroll, + harness.pages.changes.viewer.diff_scroll.row(), ); - harness.pages.changes.viewer.diff_scroll = 0; + harness.pages.changes.viewer.diff_scroll = .{ .logical = 0 }; harness.pages.changes.viewer.diff_cursor = .{ .hunk_header = 0 }; harness.controller().selectHunkDelta(1); const hunk_offset = harness.view().selectedDiffCursorOffset() orelse return error.ExpectedCursorOffset; - const hunk_row = hunk_offset - harness.pages.changes.viewer.diff_scroll; + const hunk_row = hunk_offset - harness.pages.changes.viewer.diff_scroll.row(); try std.testing.expect(hunk_row >= margin and hunk_row <= band_last); - harness.pages.changes.viewer.diff_scroll = 0; + harness.pages.changes.viewer.diff_scroll = .{ .logical = 0 }; setDiffSearchQuery(&harness, "new"); harness.controller().submitSearch(std.testing.allocator); const search_offset = harness.view().selectedDiffCursorOffset() orelse return error.ExpectedCursorOffset; - const search_row = search_offset - harness.pages.changes.viewer.diff_scroll; + const search_row = search_offset - harness.pages.changes.viewer.diff_scroll.row(); try std.testing.expect(search_row >= margin and search_row <= band_last); harness.terminal_size.height = 8; - harness.pages.changes.viewer.diff_scroll = 1; + harness.pages.changes.viewer.diff_scroll = .{ .logical = 1 }; harness.pages.changes.viewer.diff_cursor = .{ .hunk_header = 0 }; const zero_height_cursor = harness.pages.changes.viewer.diff_cursor; harness.controller().scrollDiff(.down); - try std.testing.expectEqual(@as(usize, 2), harness.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 2), harness.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(zero_height_cursor, harness.pages.changes.viewer.diff_cursor); } @@ -2796,7 +2804,7 @@ test "reload search fallback resyncs match without interactive cursor placement" .visible_sidebar_row = 0, .diff_cursor = harness.pages.changes.viewer.diff_cursor, .diff_cursor_offset = 0, - .diff_scroll = 0, + .diff_scroll = .{ .logical = 0 }, .diff_horizontal_scroll = 0, .sidebar_horizontal_scroll = 0, .search_coordinate = invalid_coordinate, @@ -2806,7 +2814,7 @@ test "reload search fallback resyncs match without interactive cursor placement" harness.controller().restoreSearchFromReloadAnchor(std.testing.allocator, &anchor); try std.testing.expectEqual(cursor_before, harness.pages.changes.viewer.diff_cursor); - try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_scroll.row()); const match = harness.pages.changes.search.match orelse return error.ExpectedSearchMatch; try std.testing.expect(!std.meta.eql(invalid_coordinate, match.coordinate)); try std.testing.expect(harness.pages.changes.search.match_offset != null); @@ -2886,6 +2894,72 @@ test "Changes mouse selection projects scrolled TAB wide combining and emoji cel try std.testing.expect(pointForTextCell(3, 4, &invalid, .character, 0, 0) == null); } +test "wrap mouse hits share paired fragments and copy only original source bytes" { + var harness = TestHarness.init(.{ + .viewer = .{ .display_mode = .side_by_side, .sidebar_hidden = true, .view_options = .{ .line_wrap = true } }, + }, .{ .width = 100, .height = 14 }); + const raw = harness.view().rawDiffPaneGeometry().?; + const pane_width = contentWidth(raw.width); + const geometry = diff_render.sideBySideGeometry(diff_render.bodyWidth(pane_width)); + const gutter = diff_render.lineTextStart(true, .side_by_side); + const text_width = geometry.new.width - gutter; + const text = try std.testing.allocator.alloc(u8, text_width - 1 + "界z".len); + defer std.testing.allocator.free(text); + @memset(text[0 .. text_width - 1], 'a'); + @memcpy(text[text_width - 1 ..], "界z"); + const lines = [_]diff_parser.DiffLine{ + .{ .kind = .removed, .text = "x", .old_line = 1 }, + .{ .kind = .added, .text = text, .new_line = 1 }, + .{ .kind = .context, .text = "next", .old_line = 2, .new_line = 2 }, + }; + const files = [_]diff_parser.FileDiff{.{ + .header = "diff --git a/a b/a", + .old_path = "a", + .new_path = "a", + .metadata = &.{}, + .hunks = &.{.{ .old_start = 1, .old_count = 2, .new_start = 1, .new_count = 2, .section = "", .lines = &lines }}, + }}; + const eligibility = [_]loaded_diff.FileTextEligibility{.selectable_utf8}; + const tree = [_]file_tree.Node{.{ .kind = .file, .name = "a", .path = "a", .path_key = "a", .depth = 0, .target = .{ .diff_file = 0 } }}; + harness.pages.changes.load = test_support.loadState(.{ + .text = "", + .document = .{ .files = &files }, + .file_text_eligibility = &eligibility, + .tree = .{ .nodes = &tree }, + .bytes = 0, + .lines = 0, + }); + const body_col = raw.col + raw.width - pane_width + diff_render.cursor_gutter_width; + const new_text = body_col + geometry.new.col + gutter; + const first_row = diff_render.body_start_row + 1; + const padding = harness.view().diffMouseHit(.{ .col = new_text + text_width - 1, .row = first_row }).?; + try std.testing.expectEqual(@as(usize, text_width - 1), padding.point.leading); + try std.testing.expectEqual(padding.point.leading, padding.point.trailing); + try std.testing.expect(harness.view().diffMouseHit(.{ .col = body_col + gutter, .row = first_row + 1 }) == null); + const continuation = harness.view().diffMouseHit(.{ .col = new_text, .row = first_row + 1 }).?; + try std.testing.expectEqual(@as(usize, text_width - 1), continuation.point.leading); + try std.testing.expectEqual(@as(usize, text_width + 2), continuation.point.trailing); + const next = harness.view().diffMouseHit(.{ .col = new_text, .row = first_row + 2 }).?; + try std.testing.expectEqual(@as(usize, 2), next.point.line_index); + + harness.controller().pressDiffMouse(.{ .col = new_text + text_width - 2, .row = first_row }); + harness.controller().dragDiffMouse(.{ .col = new_text + 2, .row = first_row + 1 }); + const selected = harness.pages.changes.selection_owner.activeDiff().?; + const copied = try diff_selection.copyText(std.testing.allocator, files[0], selected); + defer std.testing.allocator.free(copied); + try std.testing.expectEqualStrings("a界z", copied); + harness.controller().dragDiffMouse(.{ .col = new_text - 1, .row = first_row + 1 }); + try std.testing.expectEqual(@as(usize, text_width - 1), harness.pages.changes.selection_owner.activeDiff().?.focus.leading); + + var rendered: chasen.testing.TestSurface = undefined; + try rendered.init(pane_width, 8); + defer rendered.deinit(); + try diff_render.renderFile(&rendered.surface, files[0], .{ .requested_mode = .side_by_side, .line_wrap = true }); + const render_new = diff_render.cursor_gutter_width + geometry.new.col + gutter; + try rendered.expectCellText(render_new, first_row + 1, "界"); + try rendered.expectCellText(render_new, first_row + 2, "n"); +} + test "unified body always selects complete marker-prefixed diff rows" { const lines = [_]diff_parser.DiffLine{ .{ .kind = .context, .text = "ABCDEFG", .old_line = 1, .new_line = 1 }, @@ -2967,11 +3041,11 @@ test "invalid primary file is inert while its valid sibling remains selectable" try std.testing.expect(harness.view().selectedHunkIndex() == null); try std.testing.expect(harness.view().displayedDiffFile() == null); try std.testing.expect(harness.view().unsupportedSearchMessage() != null); - harness.pages.changes.viewer.diff_scroll = 99; + harness.pages.changes.viewer.diff_scroll = .{ .logical = 99 }; harness.pages.changes.viewer.diff_horizontal_scroll = 99; harness.controller().clampDiffNavigation(); try std.testing.expectEqual(diff_view_model.BodyCoordinate{ .metadata = 0 }, harness.pages.changes.viewer.diff_cursor); - try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_horizontal_scroll); const inert_snapshot = harness.view().displayNavigationSnapshot(); for ([_]diff_surface.message.Msg{ @@ -2991,7 +3065,7 @@ test "invalid primary file is inert while its valid sibling remains selectable" harness.controller().selectHunkDelta(1); harness.controller().toggleSelectedHunkFold(std.testing.allocator); try std.testing.expectEqual(diff_view_model.BodyCoordinate{ .metadata = 0 }, harness.pages.changes.viewer.diff_cursor); - try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_horizontal_scroll); harness.controller().pressDiffMouse(.{ .col = 12, .row = diff_render.body_start_row + 1 }); try std.testing.expect(harness.pages.changes.selection_owner == .none); @@ -3061,16 +3135,16 @@ test "invalid cached projection cannot fall through to primary hunk authority" { try std.testing.expect(harness.view().activeCachedDiffProjection() == null); try std.testing.expect(harness.view().displayedDiffFile() == null); try std.testing.expect(harness.view().selectedHunkIndex() == null); - harness.pages.changes.viewer.diff_scroll = 99; + harness.pages.changes.viewer.diff_scroll = .{ .logical = 99 }; harness.pages.changes.viewer.diff_horizontal_scroll = 99; harness.controller().clampDiffNavigation(); try std.testing.expectEqual(diff_view_model.BodyCoordinate{ .metadata = 0 }, harness.pages.changes.viewer.diff_cursor); - try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_horizontal_scroll); harness.controller().moveDiffCursorPage(.down); harness.controller().scrollDiff(.down); harness.controller().scrollDiffHorizontal(.right); - try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_horizontal_scroll); harness.controller().pressDiffMouse(.{ .col = 12, .row = diff_render.body_start_row + 1 }); try std.testing.expect(harness.pages.changes.selection_owner == .none); @@ -3108,7 +3182,7 @@ test "either invalid combined component remains inert without primary navigation .viewer = .{ .selected_target = .{ .diff_file = 0 }, .diff_cursor = .{ .hunk_line = .{ .hunk_index = 0, .line_index = 0 } }, - .diff_scroll = 99, + .diff_scroll = .{ .logical = 99 }, .diff_horizontal_scroll = 99, .display_mode = .unified, .sidebar_hidden = true, @@ -3151,7 +3225,7 @@ test "either invalid combined component remains inert without primary navigation harness.controller().selectHunkDelta(1); harness.controller().toggleSelectedHunkFold(std.testing.allocator); try std.testing.expectEqual(diff_view_model.BodyCoordinate{ .metadata = 0 }, harness.pages.changes.viewer.diff_cursor); - try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(usize, 0), harness.pages.changes.viewer.diff_horizontal_scroll); } } @@ -3297,7 +3371,7 @@ test "Changes navigation snapshot and reload restore share the page owner" { .selected_node = 0, .display_mode = .unified, .diff_cursor = .{ .hunk_header = 1 }, - .diff_scroll = 4, + .diff_scroll = .{ .logical = 4 }, .diff_horizontal_scroll = 3, .sidebar_horizontal_scroll = 2, }, @@ -3308,7 +3382,7 @@ test "Changes navigation snapshot and reload restore share the page owner" { }, .{ .width = 100, .height = 9 }); const snapshot = harness.view().displayNavigationSnapshot(); - try std.testing.expectEqual(@as(usize, 4), snapshot.diff_scroll); + try std.testing.expectEqual(@as(usize, 4), snapshot.diff_scroll.row()); try std.testing.expectEqual(@as(?usize, 6), snapshot.search_match_offset); var anchor: changes_page.ReloadAnchor = .{ @@ -3318,7 +3392,7 @@ test "Changes navigation snapshot and reload restore share the page owner" { .visible_sidebar_row = 0, .diff_cursor = snapshot.diff_cursor, .diff_cursor_offset = harness.view().selectedDiffCursorOffset(), - .diff_scroll = snapshot.diff_scroll, + .diff_scroll = .{ .logical = snapshot.diff_scroll.row() }, .diff_horizontal_scroll = snapshot.diff_horizontal_scroll, .sidebar_horizontal_scroll = snapshot.sidebar_horizontal_scroll, .search_coordinate = snapshot.search_coordinate, @@ -3326,7 +3400,7 @@ test "Changes navigation snapshot and reload restore share the page owner" { defer anchor.deinit(allocator); harness.pages.changes.viewer.diff_cursor = .{ .metadata = 0 }; - harness.pages.changes.viewer.diff_scroll = 0; + harness.pages.changes.viewer.diff_scroll = .{ .logical = 0 }; harness.pages.changes.viewer.diff_horizontal_scroll = 0; harness.pages.changes.viewer.sidebar_horizontal_scroll = 0; harness.pages.changes.search.match = null; @@ -3335,7 +3409,7 @@ test "Changes navigation snapshot and reload restore share the page owner" { const loaded = harness.controller().activeLoadedDiff() orelse return error.ExpectedLoadedDiff; try std.testing.expect(harness.controller().restoreReloadAnchor(std.testing.allocator, loaded, &anchor)); try std.testing.expectEqual(snapshot.diff_cursor, harness.pages.changes.viewer.diff_cursor); - try std.testing.expect(harness.pages.changes.viewer.diff_scroll >= snapshot.diff_scroll); + try std.testing.expect(harness.pages.changes.viewer.diff_scroll.row() >= snapshot.diff_scroll.row()); try std.testing.expect(harness.view().visibleDiffCursorOffset() != null); try std.testing.expect(harness.pages.changes.viewer.diff_horizontal_scroll <= snapshot.diff_horizontal_scroll); try std.testing.expect(harness.pages.changes.viewer.sidebar_horizontal_scroll <= snapshot.sidebar_horizontal_scroll); @@ -3653,7 +3727,7 @@ test "displayed body horizontal scroll follows generated visible rows" { try std.testing.expect(harness.view().diffVisibleRows() < 12); try std.testing.expectEqual(@as(usize, 0), harness.view().visibleBodyTextMaxHorizontalScroll()); - harness.pages.changes.viewer.diff_scroll = 12; + harness.pages.changes.viewer.diff_scroll = .{ .logical = 12 }; const visible_max = harness.view().visibleBodyTextMaxHorizontalScroll(); try std.testing.expect(visible_max > 0); harness.pages.changes.viewer.diff_horizontal_scroll = std.math.maxInt(usize); @@ -3755,7 +3829,7 @@ test "displayed body horizontal scroll keeps non-scrollable terminals at zero" { try std.testing.expectEqual(@as(usize, 0), status.view().visibleBodyTextMaxHorizontalScroll()); const status_cursor = status.pages.changes.viewer.diff_cursor; status.controller().scrollDiff(.down); - try std.testing.expectEqual(@as(usize, 0), status.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), status.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(status_cursor, status.pages.changes.viewer.diff_cursor); const invalid_patch = @@ -3896,7 +3970,7 @@ test "diff wheel scroll brings an invisible cursor into the moved viewport" { .viewer = .{ .display_mode = .unified, .sidebar_hidden = true, - .diff_scroll = 2, + .diff_scroll = .{ .logical = 2 }, .diff_cursor = .{ .hunk_header = 0 }, }, } }, @@ -3918,15 +3992,15 @@ test "diff wheel keeps every visible cursor screen position stable in both displ }, .{ .width = 140, .height = 10 }); const view = app.changesNavigationView(); for (0..view.diffVisibleRows()) |screen_row| { - app.pages.changes.viewer.diff_scroll = 1; + app.pages.changes.viewer.diff_scroll = .{ .logical = 1 }; app.pages.changes.viewer.diff_cursor = view.selectedCoordinateAtOffset(1 + screen_row) orelse return error.ExpectedCoordinate; app.changesNavigation().scrollDiff(.down); - try std.testing.expectEqual(@as(usize, 2), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 2), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(2 + screen_row, view.selectedDiffCursorOffset().?); app.changesNavigation().scrollDiff(.up); - try std.testing.expectEqual(@as(usize, 1), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 1), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(1 + screen_row, view.selectedDiffCursorOffset().?); } } @@ -3946,25 +4020,25 @@ test "diff wheel clamps offscreen cursors to the nearest edge only after viewpor const line_count = app.changesNavigationView().selectedFileLineIndex(app.changesNavigationView().effectiveDisplayMode()).lineCount(); const visible_rows = app.changesNavigationView().diffVisibleRows(); - app.pages.changes.viewer.diff_scroll = 0; + app.pages.changes.viewer.diff_scroll = .{ .logical = 0 }; app.pages.changes.viewer.diff_cursor = app.changesNavigationView().selectedCoordinateAtOffset(line_count - 1) orelse return error.ExpectedCoordinate; app.changesNavigation().scrollDiff(.up); - try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(line_count - 2, app.changesNavigationView().selectedDiffCursorOffset().?); app.changesNavigation().scrollDiff(.down); - try std.testing.expectEqual(@as(usize, 1), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 1), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual( - @min(line_count - 2, app.pages.changes.viewer.diff_scroll + visible_rows - 1), + @min(line_count - 2, app.pages.changes.viewer.diff_scroll.row() + visible_rows - 1), app.changesNavigationView().selectedDiffCursorOffset().?, ); - app.pages.changes.viewer.diff_scroll = line_count - visible_rows; + app.pages.changes.viewer.diff_scroll = .{ .logical = line_count - visible_rows }; app.pages.changes.viewer.diff_cursor = app.changesNavigationView().selectedCoordinateAtOffset(0) orelse return error.ExpectedCoordinate; app.changesNavigation().scrollDiff(.up); - try std.testing.expectEqual(line_count - visible_rows - 1, app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(line_count - visible_rows - 1, app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual( - app.pages.changes.viewer.diff_scroll, + app.pages.changes.viewer.diff_scroll.row(), app.changesNavigationView().selectedDiffCursorOffset().?, ); } @@ -3983,12 +4057,12 @@ test "diff wheel steps to both content edges in long and short viewports" { var expected = rows / 2; app.pages.changes.viewer.diff_cursor = view.selectedCoordinateAtOffset(expected).?; const scroll = if (direction == .up) 0 else max_scroll; - app.pages.changes.viewer.diff_scroll = scroll; + app.pages.changes.viewer.diff_scroll = .{ .logical = scroll }; for (0..rows + 2) |_| { expected = if (direction == .up) expected -| 1 else @min(expected + 1, rows - 1); app.controller().scrollDiff(direction); try std.testing.expectEqual(expected, view.selectedDiffCursorOffset().?); - try std.testing.expectEqual(scroll, app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(scroll, app.pages.changes.viewer.diff_scroll.row()); } } } @@ -4007,7 +4081,7 @@ test "diff row movement continues from wheel-synced visible cursor" { .terminal_size = .{ .width = 140, .height = 15 }, }; const line_count = app.changesNavigationView().selectedFileLineIndex(app.changesNavigationView().effectiveDisplayMode()).lineCount(); - app.pages.changes.viewer.diff_scroll = 0; + app.pages.changes.viewer.diff_scroll = .{ .logical = 0 }; app.pages.changes.viewer.diff_cursor = app.changesNavigationView().selectedCoordinateAtOffset(line_count - 1) orelse return error.ExpectedCoordinate; app.changesNavigation().scrollDiff(.down); @@ -4093,7 +4167,7 @@ test "diff scroll cursor sync keeps search state" { .viewer = .{ .display_mode = .unified, .sidebar_hidden = true, - .diff_scroll = 12, + .diff_scroll = .{ .logical = 12 }, .diff_cursor = .{ .hunk_header = 0 }, }, } }, @@ -4159,8 +4233,8 @@ test "mode change resyncs search match to rendered body offsets" { try expectSearchCoordinate(&app, .{ .hunk_line = .{ .hunk_index = 1, .line_index = 2 } }); try std.testing.expectEqual(@as(?usize, 7), app.pages.changes.search.match_offset); - try std.testing.expect(app.pages.changes.search.match_offset.? >= app.pages.changes.viewer.diff_scroll); - try std.testing.expect(app.pages.changes.search.match_offset.? < app.pages.changes.viewer.diff_scroll + app.changesNavigationView().diffVisibleRows()); + try std.testing.expect(app.pages.changes.search.match_offset.? >= app.pages.changes.viewer.diff_scroll.row()); + try std.testing.expect(app.pages.changes.search.match_offset.? < app.pages.changes.viewer.diff_scroll.row() + app.changesNavigationView().diffVisibleRows()); } test "mode change keeps search near later matches" { @@ -4430,7 +4504,7 @@ test "body file navigation preserves boundaries and resets only a successful mov setFileSearchInput(&app, "retained file filter"); app.changesNavigation().refreshSearchForSelectedFile(allocator); const token = try installDisplayedCompletedSelection(&app, allocator); - app.pages.changes.viewer.diff_scroll = 3; + app.pages.changes.viewer.diff_scroll = .{ .logical = 3 }; app.pages.changes.viewer.diff_horizontal_scroll = 7; const before = app.view().displayNavigationSnapshot(); var adapter = app.controller().updateAdapter(); @@ -4446,7 +4520,7 @@ test "body file navigation preserves boundaries and resets only a successful mov try std.testing.expect(moved.explicit_sidebar_selection_changed); try std.testing.expectEqual(context.SelectedTarget{ .diff_file = 1 }, app.pages.changes.viewer.selected_target.?); try std.testing.expectEqual(@as(usize, 1), app.pages.changes.viewer.selected_node); - try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.diff_horizontal_scroll); try std.testing.expectEqual(changes_page.Focus.diff, app.pages.changes.viewer.focus); try std.testing.expectEqual(mode, app.pages.changes.viewer.display_mode); @@ -4536,7 +4610,7 @@ test "file change resyncs retained search query to selected file" { try std.testing.expectEqual(context.SelectedTarget{ .diff_file = 1 }, app.pages.changes.viewer.selected_target.?); try expectSearchCoordinate(&app, .{ .metadata = 0 }); try std.testing.expectEqual(@as(?usize, 0), app.pages.changes.search.match_offset); - try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.diff_scroll.row()); } test "sidebar navigation can select directories without changing selected file" { @@ -5070,7 +5144,7 @@ test "changes transition exact reveal expands only target ancestors and selects .selected_target = .{ .diff_file = 0 }, .selected_node = 3, .focus = .diff, - .diff_scroll = 8, + .diff_scroll = .{ .logical = 8 }, .diff_horizontal_scroll = 3, .sidebar_horizontal_scroll = 2, .diff_cursor = .{ .hunk_header = 0 }, @@ -5101,7 +5175,7 @@ test "changes transition exact reveal expands only target ancestors and selects try std.testing.expectEqual(@as(?usize, 4), loaded.visibleNodeAt(3)); try std.testing.expectEqual(@as(usize, 4), app.pages.changes.viewer.selected_node); try std.testing.expectEqual(context.SelectedTarget{ .diff_file = 1 }, app.pages.changes.viewer.selected_target.?); - try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(usize, 3), app.pages.changes.viewer.diff_horizontal_scroll); try std.testing.expectEqual(@as(usize, 2), app.pages.changes.viewer.sidebar_horizontal_scroll); try std.testing.expectEqual(changes_page.Focus.diff, app.pages.changes.viewer.focus); @@ -5149,12 +5223,12 @@ test "changes transition exact reveal selects status-only and reports unchanged" try std.testing.expectEqual(context.SelectedTarget{ .status_only = 0 }, app.pages.changes.viewer.selected_target.?); try std.testing.expectEqual(diff_render.DisplayMode.unified, app.pages.changes.viewer.display_mode); - app.pages.changes.viewer.diff_scroll = 9; + app.pages.changes.viewer.diff_scroll = .{ .logical = 9 }; try expectExactPathRevealUnchanged( try app.changesNavigation().revealExactPath(std.testing.allocator, exactChangesIntent(&app, "new.zig")), 1, ); - try std.testing.expectEqual(@as(usize, 9), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 9), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(context.SelectedTarget{ .status_only = 0 }, app.pages.changes.viewer.selected_target.?); } @@ -5236,7 +5310,7 @@ test "changes transition unavailable exact reveal preserves navigation folds and .viewer = .{ .selected_target = .{ .diff_file = 1 }, .selected_node = 2, - .diff_scroll = 6, + .diff_scroll = .{ .logical = 6 }, .diff_horizontal_scroll = 2, .diff_cursor = .{ .metadata = 1 }, }, @@ -5257,7 +5331,7 @@ test "changes transition unavailable exact reveal preserves navigation folds and try std.testing.expect(app.pages.changes.review_display.hide_reviewed_files); try std.testing.expectEqual(@as(usize, 2), app.pages.changes.viewer.selected_node); try std.testing.expectEqual(context.SelectedTarget{ .diff_file = 1 }, app.pages.changes.viewer.selected_target.?); - try std.testing.expectEqual(@as(usize, 6), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 6), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(usize, 2), app.pages.changes.viewer.diff_horizontal_scroll); try std.testing.expect(std.meta.eql( diff_view_model.BodyCoordinate{ .metadata = 1 }, @@ -5274,7 +5348,7 @@ test "changes transition exact reveal allocation failure rolls back before ances .selected_target = .{ .diff_file = 0 }, .selected_node = 1, .focus = .diff, - .diff_scroll = 7, + .diff_scroll = .{ .logical = 7 }, .diff_horizontal_scroll = 4, .sidebar_horizontal_scroll = 3, .diff_cursor = .{ .hunk_header = 0 }, @@ -5308,7 +5382,7 @@ test "changes transition exact reveal allocation failure rolls back before ances try std.testing.expectEqual(visible_before, loaded.visibleNodeCount()); try std.testing.expectEqual(@as(usize, 1), app.pages.changes.viewer.selected_node); try std.testing.expectEqual(context.SelectedTarget{ .diff_file = 0 }, app.pages.changes.viewer.selected_target.?); - try std.testing.expectEqual(@as(usize, 7), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 7), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(usize, 4), app.pages.changes.viewer.diff_horizontal_scroll); try std.testing.expectEqual(@as(usize, 3), app.pages.changes.viewer.sidebar_horizontal_scroll); try std.testing.expectEqual(changes_page.Focus.diff, app.pages.changes.viewer.focus); @@ -5737,10 +5811,10 @@ test "generated preview uses metadata cursor rows and ignores hunk movement" { app.visibleDiffCursorOffset(), ); - app.pages.changes.viewer.diff_scroll = 0; + app.pages.changes.viewer.diff_scroll = .{ .logical = 0 }; app.pages.changes.viewer.diff_cursor = .{ .metadata = 0 }; app.changesNavigation().scrollDiff(.down); - try std.testing.expectEqual(@as(usize, 1), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 1), app.pages.changes.viewer.diff_scroll.row()); // The cursor stays on the first visible row after scrolling. try std.testing.expectEqual(diff_view_model.BodyCoordinate{ .metadata = 1 }, app.pages.changes.viewer.diff_cursor); } diff --git a/src/app/pages/changes/navigation_app_test.zig b/src/app/pages/changes/navigation_app_test.zig index 4d0f1d7..c24aabb 100644 --- a/src/app/pages/changes/navigation_app_test.zig +++ b/src/app/pages/changes/navigation_app_test.zig @@ -134,18 +134,18 @@ test "line number toggle clamps horizontal scroll without changing vertical scro .viewer = .{ .display_mode = .unified, .sidebar_hidden = true, - .diff_scroll = 2, + .diff_scroll = .{ .logical = 2 }, .diff_horizontal_scroll = 999, }, } }, .terminal_size = .{ .width = 80, .height = 8 }, }; - const old_scroll = app.pages.changes.viewer.diff_scroll; + const old_scroll = app.pages.changes.viewer.diff_scroll.row(); try app.update(.{ .changes = .toggle_line_numbers }, undefined); try std.testing.expect(!app.pages.changes.viewer.view_options.line_numbers); - try std.testing.expectEqual(old_scroll, app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(old_scroll, app.pages.changes.viewer.diff_scroll.row()); try std.testing.expect(app.pages.changes.viewer.diff_horizontal_scroll <= changesNavigationView(&app).visibleBodyTextMaxHorizontalScroll()); } @@ -156,19 +156,19 @@ test "display mode toggle keeps nearby vertical scroll position" { .load = app_test_support.loadState(app_test_support.loadedDiffOne()), .viewer = .{ .display_mode = .unified, - .diff_scroll = 8, + .diff_scroll = .{ .logical = 8 }, .sidebar_hidden = true, }, } }, .terminal_size = .{ .width = 140, .height = 8 }, }; - app.pages.changes.viewer.diff_cursor = changesNavigationView(&app).selectedCoordinateAtOffset(app.pages.changes.viewer.diff_scroll) orelse app.pages.changes.viewer.diff_cursor; + app.pages.changes.viewer.diff_cursor = changesNavigationView(&app).selectedCoordinateAtOffset(app.pages.changes.viewer.diff_scroll.row()) orelse app.pages.changes.viewer.diff_cursor; try app.update(.{ .changes = .toggle_display_mode }, undefined); try std.testing.expectEqual(diff_render.DisplayMode.side_by_side, changesNavigationView(&app).effectiveDisplayMode()); - try std.testing.expect(app.pages.changes.viewer.diff_scroll > 0); - try std.testing.expect(app.pages.changes.viewer.diff_scroll <= changesNavigationView(&app).selectedFileLineIndex(changesNavigationView(&app).effectiveDisplayMode()).lineCount()); + try std.testing.expect(app.pages.changes.viewer.diff_scroll.row() > 0); + try std.testing.expect(app.pages.changes.viewer.diff_scroll.row() <= changesNavigationView(&app).selectedFileLineIndex(changesNavigationView(&app).effectiveDisplayMode()).lineCount()); } test "display mode toggle brings cursor back into view after wheel scroll" { @@ -179,7 +179,7 @@ test "display mode toggle brings cursor back into view after wheel scroll" { .viewer = .{ .display_mode = .unified, .sidebar_hidden = true, - .diff_scroll = 12, + .diff_scroll = .{ .logical = 12 }, .diff_cursor = .{ .hunk_header = 0 }, }, } }, @@ -201,26 +201,26 @@ test "diff wheel routes through Changes and preserves an edge cursor screen row" .display_mode = .unified, .sidebar_hidden = true, .focus = .sidebar, - .diff_scroll = 2, + .diff_scroll = .{ .logical = 2 }, }, } }, .terminal_size = .{ .width = 140, .height = 15 }, }; - const old_scroll = app.pages.changes.viewer.diff_scroll; + const old_scroll = app.pages.changes.viewer.diff_scroll.row(); app.pages.changes.viewer.diff_cursor = changesNavigationView(&app).selectedCoordinateAtOffset(old_scroll) orelse return error.ExpectedCoordinate; try app.update(.{ .changes = .mouse_diff_wheel_down }, undefined); try std.testing.expectEqual(changes_page.Focus.diff, app.pages.changes.viewer.focus); - try std.testing.expectEqual(old_scroll + 1, app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(old_scroll + 1, app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual( - app.pages.changes.viewer.diff_scroll, + app.pages.changes.viewer.diff_scroll.row(), changesNavigationView(&app).selectedDiffCursorOffset().?, ); try app.update(.{ .changes = .mouse_diff_wheel_up }, undefined); - try std.testing.expectEqual(old_scroll, app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(old_scroll, app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(old_scroll, changesNavigationView(&app).selectedDiffCursorOffset().?); } @@ -426,7 +426,7 @@ test "Changes document navigation updates pending restore only on display change .visible_sidebar_row = 0, .diff_cursor = .{ .hunk_header = 1 }, .diff_cursor_offset = 5, - .diff_scroll = 4, + .diff_scroll = .{ .logical = 4 }, .diff_horizontal_scroll = 0, .sidebar_horizontal_scroll = 0, .search_coordinate = null, @@ -462,19 +462,19 @@ test "Changes document navigation updates pending restore only on display change override = restore.override orelse return error.ExpectedNavigationOverride; const latest_cursor = app.pages.changes.viewer.diff_cursor; const latest_cursor_offset = changesNavigationView(&app).selectedDiffCursorOffset(); - const latest_scroll = app.pages.changes.viewer.diff_scroll; + const latest_scroll = app.pages.changes.viewer.diff_scroll.row(); try std.testing.expectEqual(latest_cursor, override.diff_cursor); try std.testing.expectEqual(latest_cursor_offset, override.diff_cursor_offset); - try std.testing.expectEqual(latest_scroll, override.diff_scroll); + try std.testing.expectEqual(latest_scroll, override.diff_scroll.row()); // A delayed projection completion restores the latest user override, not // the acceptance-time cursor and viewport. app.pages.changes.viewer.diff_cursor = restore.original.diff_cursor; - app.pages.changes.viewer.diff_scroll = restore.original.diff_scroll; + app.pages.changes.viewer.diff_scroll = .{ .logical = restore.original.diff_scroll.row() }; changesReload(&app).restoreDisplayedNavigation(std.testing.allocator, restore.authoritative()); try std.testing.expectEqual(latest_cursor, app.pages.changes.viewer.diff_cursor); try std.testing.expectEqual(latest_cursor_offset, changesNavigationView(&app).selectedDiffCursorOffset()); - try std.testing.expectEqual(latest_scroll, app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(latest_scroll, app.pages.changes.viewer.diff_scroll.row()); const revision_before_edge = app.pages.changes.display_navigation_input_revision; try app.update(.{ .changes = .document_last }, &ctx.ctx); @@ -504,7 +504,7 @@ test "Changes drag auto-scroll advances input revision and captures pending rest .viewer = .{ .selected_target = .{ .diff_file = 0 }, .diff_cursor = .{ .hunk_line = .{ .hunk_index = 0, .line_index = 1 } }, - .diff_scroll = 1, + .diff_scroll = .{ .logical = 1 }, .sidebar_hidden = true, .display_mode = .unified, }, @@ -537,7 +537,7 @@ test "Changes drag auto-scroll advances input revision and captures pending rest .visible_sidebar_row = 0, .diff_cursor = .{ .hunk_header = 1 }, .diff_cursor_offset = 5, - .diff_scroll = 4, + .diff_scroll = .{ .logical = 4 }, .diff_horizontal_scroll = 0, .sidebar_horizontal_scroll = 0, .search_coordinate = null, @@ -553,11 +553,11 @@ test "Changes drag auto-scroll advances input revision and captures pending rest .endpoint = .{ .col = 20, .row = diff_render.body_start_row }, } } }, &ctx.ctx); - try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(u64, 1), app.pages.changes.display_navigation_input_revision); const restore = app.pages.changes.pending_display_navigation_restore orelse return error.ExpectedPendingDisplayRestore; const override = restore.override orelse return error.ExpectedNavigationOverride; - try std.testing.expectEqual(@as(usize, 0), override.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), override.diff_scroll.row()); // A further timer tick at BOF cannot retarget the cursor or endpoint. app.pages.changes.viewer.diff_cursor = .{ .hunk_line = .{ .hunk_index = 0, .line_index = 1 } }; @@ -567,7 +567,129 @@ test "Changes drag auto-scroll advances input revision and captures pending rest .direction = .up, .endpoint = .{ .col = 20, .row = diff_render.body_start_row }, } } }, &ctx.ctx); - try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqualDeep(edge_cursor, app.pages.changes.viewer.diff_cursor); try std.testing.expectEqualDeep(edge_owner, app.pages.changes.selection_owner); } + +test "wrap viewport reaches a tall line tail and keeps search and j k line based" { + const allocator = std.testing.allocator; + const patch = "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1,4 +1,4 @@\n " ++ + "needle" ++ "x" ** 400 ++ "needle END\n needle next\n third\n fourth\n"; + var bundle = try app_load.buildLoadedBundle(allocator, patch); + var app: App = .{ + .allocator = allocator, + .pages = .{ .changes = .{ + .load = app_test_support.loadStateWithArena(bundle.takeArena(), bundle.loaded), + .viewer = .{ .selected_target = .{ .diff_file = 0 }, .sidebar_hidden = true, .display_mode = .unified, .view_options = .{ .line_wrap = true }, .diff_cursor = .{ .hunk_line = .{ .hunk_index = 0, .line_index = 0 } } }, + } }, + .terminal_size = .{ .width = 40, .height = 14 }, + }; + defer app.pages.changes.deinit(allocator); + setDiffSearchQuery(&app, "needle"); + changesNavigation(&app).submitSearch(allocator); + const first_match = app.pages.changes.search.match.?.coordinate; + try app.update(.{ .changes = .page_diff_down }, undefined); + try std.testing.expect(app.pages.changes.viewer.diff_scroll == .wrapped); + try std.testing.expect(app.pages.changes.viewer.diff_scroll.wrapped.anchor.byte > 0); + try std.testing.expectEqualDeep(first_match, app.pages.changes.viewer.diff_cursor); + + var found_tail = false; + for (0..12) |_| { + const nav = changesNavigationView(&app); + const changes_view = @import("view.zig"); + const ctx = changes_view.Context.init(&app.pages.changes, nav, .default(), .{}, "working tree", .unstaged, null, .{}); + var ts: chasen.testing.TestSurface = undefined; + try ts.init(nav.diffPaneWidth(), nav.layout.height); + defer ts.deinit(); + const before = app.pages.changes.viewer.diff_scroll; + try changes_view.viewDiffPane(ctx, &ts.surface, app.pages.changes.load.state.loaded.loaded); + try std.testing.expectEqualDeep(before, app.pages.changes.viewer.diff_scroll); + // The marker follows this source row's first visible continuation. + try ts.expectCellText(0, diff_render.body_start_row, "»"); + const snapshot = try ts.snapshot(allocator); + defer allocator.free(snapshot); + if (std.mem.indexOf(u8, snapshot, "END") != null) { + found_tail = true; + break; + } + try app.update(.{ .changes = .page_diff_down }, undefined); + } + try std.testing.expect(found_tail); + try app.update(.{ .changes = .scroll_diff_down }, undefined); + try std.testing.expectEqual(@as(usize, 1), app.pages.changes.viewer.diff_cursor.hunk_line.line_index); + try app.update(.{ .changes = .scroll_diff_up }, undefined); + try std.testing.expectEqualDeep(first_match, app.pages.changes.viewer.diff_cursor); + try std.testing.expectEqual(@as(usize, 0), changesNavigationView(&app).renderWrapStart()); + changesNavigation(&app).selectSearchMatch(allocator, .forward); + try std.testing.expectEqual(@as(usize, 1), app.pages.changes.search.match.?.coordinate.hunk_line.line_index); + changesNavigation(&app).selectSearchMatch(allocator, .forward); + try std.testing.expectEqualDeep(first_match, app.pages.changes.search.match.?.coordinate); +} + +test "wrap source anchor survives reflow and exact reload and drops on content change" { + const allocator = std.testing.allocator; + const patch = "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1,2 +1,2 @@\n-" ++ "old" ** 180 ++ "\n+short\n next\n"; + var bundle = try app_load.buildLoadedBundle(allocator, patch); + var app: App = .{ + .allocator = allocator, + .pages = .{ .changes = .{ + .load = app_test_support.loadStateWithArena(bundle.takeArena(), bundle.loaded), + .viewer = .{ .selected_target = .{ .diff_file = 0 }, .sidebar_hidden = true, .display_mode = .side_by_side, .view_options = .{ .line_wrap = true }, .diff_cursor = .{ .hunk_line = .{ .hunk_index = 0, .line_index = 0 } } }, + } }, + .terminal_size = .{ .width = 100, .height = 14 }, + }; + defer app.pages.changes.deinit(allocator); + try app.update(.{ .changes = .page_diff_down }, undefined); + const saved = app.pages.changes.viewer.diff_scroll.wrapped.anchor; + try std.testing.expectEqual(@import("../../../diff/selection.zig").Side.old, saved.side.?); + try std.testing.expect(saved.byte > 0); + const initial_nav = changesNavigationView(&app); + const raw = initial_nav.rawDiffPaneGeometry().?; + const text_col = raw.col + raw.width - initial_nav.diffPaneWidth() + diff_render.cursor_gutter_width + diff_render.lineTextStart(true, .side_by_side); + changesNavigation(&app).pressDiffMouse(.{ .col = text_col, .row = diff_render.body_start_row }); + changesNavigation(&app).dragDiffMouse(.{ .col = text_col + 4, .row = diff_render.body_start_row }); + var release_adapter = changesNavigation(&app).updateAdapter(); + var released = try release_adapter.shared().apply(allocator, .{ .mouse_diff_release = null }); + defer released.deinit(allocator); + const selected_bytes = try app.pages.changes.completed_selection.?.clipboardText(allocator); + defer allocator.free(selected_bytes); + // All existing reflow routes preserve the actual old-side token, even + // through narrow unified fallback. Cursor and action coordinates stay source based. + try app.update(.{ .terminal_resized = .{ .width = 80, .height = 14 } }, undefined); + try std.testing.expectEqualDeep(saved, app.pages.changes.viewer.diff_scroll.wrapped.anchor); + try app.update(.{ .terminal_resized = .{ .width = 1, .height = 14 } }, undefined); + try std.testing.expectEqualDeep(saved, app.pages.changes.viewer.diff_scroll.wrapped.anchor); + try app.update(.{ .terminal_resized = .{ .width = 80, .height = 14 } }, undefined); + try std.testing.expectEqualDeep(saved, app.pages.changes.viewer.diff_scroll.wrapped.anchor); + try app.update(.{ .changes = .toggle_line_numbers }, undefined); + try std.testing.expectEqualDeep(saved, app.pages.changes.viewer.diff_scroll.wrapped.anchor); + try app.update(.{ .changes = .toggle_display_mode }, undefined); + try std.testing.expectEqualDeep(saved, app.pages.changes.viewer.diff_scroll.wrapped.anchor); + try std.testing.expect(changesNavigationView(&app).visibleDiffCursorOffset() != null); + const retained_bytes = try app.pages.changes.completed_selection.?.clipboardText(allocator); + defer allocator.free(retained_bytes); + try std.testing.expectEqualStrings(selected_bytes, retained_bytes); + const nav = changesNavigationView(&app); + var resolver = nav.contentResolverAdapter(); + var copied = (try nav.contentView(&resolver).currentLineCopyText(allocator)).?; + defer copied.deinit(allocator); + try std.testing.expectEqualStrings("-" ++ "old" ** 180, copied.text()); + + var anchor = (try changesReload(&app).view().captureAnchor(allocator)).?; + defer anchor.deinit(allocator); + var exact = try app_load.buildLoadedBundle(allocator, patch); + app.pages.changes.load.clearCurrent(allocator); + app.pages.changes.load = app_test_support.loadStateWithArena(exact.takeArena(), exact.loaded); + try std.testing.expect(changesNavigation(&app).restoreReloadAnchor(allocator, changesNavigation(&app).activeLoadedDiff().?, &anchor)); + try std.testing.expectEqualDeep(saved, app.pages.changes.viewer.diff_scroll.wrapped.anchor); + var changed = try app_load.buildLoadedBundle(allocator, "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1,2 +1,2 @@\n-" ++ "OLD" ** 180 ++ "\n+short\n next\n"); + app.pages.changes.load.clearCurrent(allocator); + app.pages.changes.load = app_test_support.loadStateWithArena(changed.takeArena(), changed.loaded); + try std.testing.expect(changesNavigation(&app).restoreReloadAnchor(allocator, changesNavigation(&app).activeLoadedDiff().?, &anchor)); + try std.testing.expectEqual(@as(usize, 0), changesNavigationView(&app).renderWrapStart()); + try app.update(.{ .changes = .toggle_hunk_fold }, undefined); + try std.testing.expect(app.pages.changes.viewer.diff_cursor == .hunk_header); + try std.testing.expectEqual(@as(usize, 0), changesNavigationView(&app).renderWrapStart()); + try std.testing.expect(changesNavigationView(&app).visibleDiffCursorOffset() != null); +} diff --git a/src/app/pages/changes/operations.zig b/src/app/pages/changes/operations.zig index 2befaff..84e2d57 100644 --- a/src/app/pages/changes/operations.zig +++ b/src/app/pages/changes/operations.zig @@ -958,9 +958,10 @@ test "Changes raw hunk bytes reach and leave the Git index without collateral ch new: []const u8, worktree: ?[]const u8 = null, textconv: bool = false, + wrap: bool = false, }; const cases = [_]Case{ - .{ .old = "old\n", .new = "new\n" }, + .{ .old = "old" ** 180 ++ "\n", .new = "new\n", .wrap = true }, .{ .old = "old\r\n", .new = "new\r\n" }, .{ .old = "same\r\nold\n", .new = "same\r\nnew\r\n" }, .{ .old = "old", .new = "new" }, @@ -1030,6 +1031,19 @@ test "Changes raw hunk bytes reach and leave the Git index without collateral ch try page.git_status.replace("/repo", &status); acceptTestSource(&page); const view = testView(&page, .unstaged); + if (case.wrap) { + page.viewer.sidebar_hidden = true; + page.viewer.view_options.line_wrap = true; + page.viewer.diff_cursor = .{ .hunk_line = .{ .hunk_index = 0, .line_index = 0 } }; + const nav: navigation.Controller = .{ .page = &page, .repo_root = "/repo", .source = .unstaged, .layout = .{ .width = 100, .height = 10 }, .diagnostics = .{ .target = &page.status } }; + nav.moveDiffCursorPage(.down); + try std.testing.expect(page.viewer.diff_scroll == .wrapped); + try std.testing.expect(page.viewer.diff_scroll.wrapped.anchor.byte > 0); + var adapter = nav.updateAdapter(); + var reflow = try adapter.shared().apply(allocator, .toggle_display_mode); + defer reflow.deinit(allocator); + try std.testing.expectEqual(@as(usize, 0), nav.view().selectedHunkIndex().?); + } const stage = view.selectedHunkStageTarget(allocator); try std.testing.expect(stage == .ready); defer allocator.free(stage.ready.patch); diff --git a/src/app/pages/changes/read_coordinator_test.zig b/src/app/pages/changes/read_coordinator_test.zig index 6d51395..cc9fd9a 100644 --- a/src/app/pages/changes/read_coordinator_test.zig +++ b/src/app/pages/changes/read_coordinator_test.zig @@ -571,7 +571,7 @@ fn canonicalPublicationTestApp( .selected_target = .{ .diff_file = 0 }, .selected_node = 0, .diff_cursor = .{ .hunk_header = 1 }, - .diff_scroll = 1, + .diff_scroll = .{ .logical = 1 }, .diff_horizontal_scroll = 2, .sidebar_horizontal_scroll = 1, }, @@ -624,7 +624,7 @@ fn canonicalPublicationPrimaryTestApp( .selected_target = .{ .diff_file = 0 }, .selected_node = 0, .diff_cursor = .{ .hunk_header = 1 }, - .diff_scroll = 1, + .diff_scroll = .{ .logical = 1 }, .diff_horizontal_scroll = 2, .sidebar_horizontal_scroll = 1, }, @@ -688,7 +688,7 @@ fn ordinaryPrimaryPublicationTestApp( .selected_target = .{ .diff_file = 0 }, .selected_node = 0, .diff_cursor = .{ .hunk_header = 0 }, - .diff_scroll = 1, + .diff_scroll = .{ .logical = 1 }, .diff_horizontal_scroll = 2, .sidebar_horizontal_scroll = 1, }, @@ -1054,7 +1054,7 @@ fn expectFreshCanonicalActionCapabilities( else => return error.ExpectedFreshFileUnstageCapability, } - app.pages.changes.viewer.diff_scroll = 0; + app.pages.changes.viewer.diff_scroll = .{ .logical = 0 }; app.pages.changes.viewer.diff_cursor = .{ .hunk_header = 0 }; switch (app.changesOperations().selectedHunkUnstageTarget(allocator)) { .ready => |target| { @@ -1187,7 +1187,7 @@ fn expectFreshCanonicalCachedPublication( else => return error.ExpectedFreshFileUnstageCapability, } - app.pages.changes.viewer.diff_scroll = 0; + app.pages.changes.viewer.diff_scroll = .{ .logical = 0 }; app.pages.changes.viewer.diff_cursor = .{ .hunk_header = 0 }; switch (app.changesOperations().selectedHunkToggleOperation()) { .operation => |operation| try std.testing.expectEqual( @@ -1337,7 +1337,7 @@ fn expectOrdinaryPrimaryNoTargetPublication( const source_revision_before = app.pages.changes.source_session_revision; const status_revision_before = app.pages.changes.status_snapshot_revision; const cursor_before = app.pages.changes.viewer.diff_cursor; - const scroll_before = app.pages.changes.viewer.diff_scroll; + const scroll_before = app.pages.changes.viewer.diff_scroll.row(); const horizontal_before = app.pages.changes.viewer.diff_horizontal_scroll; const sidebar_horizontal_before = app.pages.changes.viewer.sidebar_horizontal_scroll; @@ -1379,7 +1379,7 @@ fn expectOrdinaryPrimaryNoTargetPublication( app.pages.changes.viewer.selected_target.?, ); try std.testing.expectEqual(cursor_before, app.pages.changes.viewer.diff_cursor); - try std.testing.expectEqual(scroll_before, app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(scroll_before, app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual( horizontal_before, app.pages.changes.viewer.diff_horizontal_scroll, @@ -1500,7 +1500,7 @@ fn expectOrdinaryPrimaryNoTargetPublication( )); try std.testing.expect(app.pages.changes.search.match != null); try std.testing.expect( - app.pages.changes.viewer.diff_scroll < + app.pages.changes.viewer.diff_scroll.row() < app.changesNavigationView().displayedDiffLineCount(), ); try std.testing.expect( @@ -1523,7 +1523,7 @@ fn expectOrdinaryPrimaryNoTargetPublication( .{ .content = token_before, .display_hunk_index = 1 }, )); try std.testing.expectEqual(cursor_before, app.pages.changes.viewer.diff_cursor); - try std.testing.expectEqual(scroll_before, app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(scroll_before, app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual( horizontal_before, app.pages.changes.viewer.diff_horizontal_scroll, @@ -2850,7 +2850,7 @@ test "Changes ordinary primary publication retains primary until cached result" try std.testing.expectEqualStrings("new", app.pages.changes.search.query.slice()); try std.testing.expect(app.pages.changes.search.match != null); try std.testing.expect( - app.pages.changes.viewer.diff_scroll < + app.pages.changes.viewer.diff_scroll.row() < app.changesNavigationView().displayedDiffLineCount(), ); try std.testing.expect( @@ -2965,7 +2965,7 @@ test "Changes canonical publication exact acceptance retains navigation search a ); const selected_tail = app.changesNavigationView().displayedDiffLineCount() -| app.changesNavigationView().diffVisibleRows(); - app.pages.changes.viewer.diff_scroll = selected_tail; + app.pages.changes.viewer.diff_scroll = .{ .logical = selected_tail }; const selection_viewport_before = app.changesNavigationView().captureSelectionViewportAnchor() orelse return error.ExpectedSelectionViewportAnchor; try std.testing.expectEqual(selected_tail, selection_viewport_before.raw_presentation_scroll); @@ -2974,7 +2974,7 @@ test "Changes canonical publication exact acceptance retains navigation search a .none, .generated_row => return error.ExpectedParsedSelectionViewportSource, } const cursor_before = app.pages.changes.viewer.diff_cursor; - const scroll_before = app.pages.changes.viewer.diff_scroll; + const scroll_before = app.pages.changes.viewer.diff_scroll.row(); const token_before = app.pages.changes.completed_selection.?.token; const clipboard_before = try app.pages.changes.completed_selection.?.clipboardText(allocator); defer allocator.free(clipboard_before); @@ -3009,7 +3009,7 @@ test "Changes canonical publication exact acceptance retains navigation search a try finishCanonicalPublicationBranch(&app, &ctx.ctx, allocator, roots.a, reads); try std.testing.expectEqual(cursor_before, app.pages.changes.viewer.diff_cursor); - try std.testing.expectEqual(scroll_before, app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(scroll_before, app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(horizontal_before, app.pages.changes.viewer.diff_horizontal_scroll); try std.testing.expectEqual(sidebar_horizontal_before, app.pages.changes.viewer.sidebar_horizontal_scroll); try std.testing.expectEqualStrings("staged", app.pages.changes.search.query.slice()); @@ -3023,7 +3023,7 @@ test "Changes canonical publication exact acceptance retains navigation search a try std.testing.expect(selection_viewport_before.basis.eql(selection_viewport_after.basis)); try std.testing.expectEqual( selection_viewport_before.raw_presentation_scroll, - app.pages.changes.viewer.diff_scroll, + app.pages.changes.viewer.diff_scroll.row(), ); const token_after = app.changesNavigationView().currentContentToken() orelse return error.ExpectedContentToken; @@ -3458,7 +3458,7 @@ test "Changes canonical publication projection failure publishes failure body at _ = try installCanonicalPublicationLineageOwners(&app, allocator, roots.a); const selected_tail = app.changesNavigationView().displayedDiffLineCount() -| app.changesNavigationView().diffVisibleRows(); - app.pages.changes.viewer.diff_scroll = selected_tail; + app.pages.changes.viewer.diff_scroll = .{ .logical = selected_tail }; const selection_viewport_before = app.changesNavigationView().captureSelectionViewportAnchor() orelse return error.ExpectedSelectionViewportAnchor; try std.testing.expectEqual(selected_tail, selection_viewport_before.raw_presentation_scroll); @@ -3513,9 +3513,9 @@ test "Changes canonical publication projection failure publishes failure body at try std.testing.expect(app.pages.changes.completed_selection == null); try std.testing.expectEqual( @as(usize, 0), - app.changesNavigationView().restoredSelectionViewportScroll(selection_viewport_before), + app.changesNavigationView().restoredViewport(selection_viewport_before).row(), ); - try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expect(!changes_read.testing.readBusy(app.changesRead())); } @@ -3939,7 +3939,7 @@ test "unchanged full cycle preserves projection semantic identity" { .load = .{ .generation = 7, .state = .{ .loaded = app_test_support.loadedSession(app_test_support.loadedDiffOne()) } }, .source_session_revision = 17, .status_snapshot_revision = 19, - .viewer = .{ .selected_target = .{ .diff_file = 0 }, .diff_cursor = .{ .hunk_header = 1 }, .diff_scroll = 2 }, + .viewer = .{ .selected_target = .{ .diff_file = 0 }, .diff_cursor = .{ .hunk_header = 1 }, .diff_scroll = .{ .logical = 2 } }, } }, .allocator = std.testing.allocator, .terminal_size = .{ .width = 100, .height = 40 }, @@ -3976,7 +3976,7 @@ test "unchanged full cycle preserves projection semantic identity" { const projection_before = app.changesNavigationView().activeCombinedProjection() orelse return error.ExpectedCombinedProjection; const hunks_before = projection_before.displayFile().hunks.ptr; const cursor_before = app.pages.changes.viewer.diff_cursor; - const scroll_before = app.pages.changes.viewer.diff_scroll; + const scroll_before = app.pages.changes.viewer.diff_scroll.row(); const status_generation = app.pages.changes.status_load.prepare(true); app.pages.changes.status_load.begin(1, .{}); @@ -3999,7 +3999,7 @@ test "unchanged full cycle preserves projection semantic identity" { try std.testing.expectEqual(@as(u64, 17), app.pages.changes.source_session_revision); try std.testing.expectEqual(@as(u64, 19), app.pages.changes.status_snapshot_revision); try std.testing.expectEqual(cursor_before, app.pages.changes.viewer.diff_cursor); - try std.testing.expectEqual(scroll_before, app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(scroll_before, app.pages.changes.viewer.diff_scroll.row()); } test "final projection prefers explicit interim navigation override" { @@ -4050,7 +4050,7 @@ test "final projection prefers explicit interim navigation override" { .visible_sidebar_row = 0, .diff_cursor = .{ .hunk_header = 1 }, .diff_cursor_offset = 5, - .diff_scroll = 4, + .diff_scroll = .{ .logical = 4 }, .diff_horizontal_scroll = 0, .sidebar_horizontal_scroll = 0, .search_coordinate = null, @@ -4062,7 +4062,7 @@ test "final projection prefers explicit interim navigation override" { .visible_sidebar_row = 0, .diff_cursor = .{ .hunk_header = 0 }, .diff_cursor_offset = 0, - .diff_scroll = 0, + .diff_scroll = .{ .logical = 0 }, .diff_horizontal_scroll = 0, .sidebar_horizontal_scroll = 0, .search_coordinate = null, @@ -4101,7 +4101,7 @@ test "empty watch source carries combined navigation into cached projection" { .status_load = .{ .generation = 7, .pending = .{ .generation = 7, .origin = .background, .background_cycle_id = 1 } }, .source_session_revision = 37, .status_snapshot_revision = 41, - .viewer = .{ .selected_target = .{ .diff_file = 0 }, .diff_cursor = .{ .hunk_header = 1 }, .diff_scroll = 3 }, + .viewer = .{ .selected_target = .{ .diff_file = 0 }, .diff_cursor = .{ .hunk_header = 1 }, .diff_scroll = .{ .logical = 3 } }, .pending_reload = .{ .generation = 2, .kind = .watch }, } }, .allocator = std.testing.allocator, @@ -4404,7 +4404,7 @@ test "selected path change supersedes pending display restore" { .visible_sidebar_row = 0, .diff_cursor = .{ .hunk_header = 0 }, .diff_cursor_offset = 0, - .diff_scroll = 0, + .diff_scroll = .{ .logical = 0 }, .diff_horizontal_scroll = 0, .sidebar_horizontal_scroll = 0, .search_coordinate = null, @@ -4466,7 +4466,7 @@ test "file search selection remains authoritative through successor projection a .visible_sidebar_row = 0, .diff_cursor = .{ .hunk_header = 0 }, .diff_cursor_offset = 0, - .diff_scroll = 0, + .diff_scroll = .{ .logical = 0 }, .diff_horizontal_scroll = 0, .sidebar_horizontal_scroll = 0, .search_coordinate = null, @@ -5316,7 +5316,7 @@ test "stale diff result does not clear newer pending reload metadata" { .visible_sidebar_row = 0, .diff_cursor = .{ .hunk_header = 0 }, .diff_cursor_offset = 0, - .diff_scroll = 0, + .diff_scroll = .{ .logical = 0 }, .diff_horizontal_scroll = 0, .sidebar_horizontal_scroll = 0, .search_coordinate = null, @@ -5344,7 +5344,7 @@ test "watch no-op diff load preserves session view state and staged hunk marks" .selected_target = .{ .diff_file = 0 }, .selected_node = 0, .diff_cursor = .{ .hunk_header = 1 }, - .diff_scroll = 3, + .diff_scroll = .{ .logical = 3 }, .diff_horizontal_scroll = 4, .sidebar_horizontal_scroll = 2, }, @@ -5371,7 +5371,7 @@ test "watch no-op diff load preserves session view state and staged hunk marks" }); try std.testing.expectEqual(diff_view_model.BodyCoordinate{ .hunk_header = 1 }, app.pages.changes.viewer.diff_cursor); - try std.testing.expectEqual(@as(usize, 3), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 3), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(usize, 4), app.pages.changes.viewer.diff_horizontal_scroll); try std.testing.expectEqual(@as(usize, 2), app.pages.changes.viewer.sidebar_horizontal_scroll); try std.testing.expect(app.pages.changes.staged_hunks.containsExact("/repo", "a", mark_key)); @@ -5399,7 +5399,7 @@ test "changed watch reload restores acceptance-time navigation instead of launch .visible_sidebar_row = 0, .diff_cursor = .{ .metadata = 0 }, .diff_cursor_offset = 0, - .diff_scroll = 0, + .diff_scroll = .{ .logical = 0 }, .diff_horizontal_scroll = 0, .sidebar_horizontal_scroll = 0, .search_coordinate = null, @@ -5454,7 +5454,7 @@ test "unchanged recovery clears its source failure and redraws" { .visible_sidebar_row = 0, .diff_cursor = .{ .hunk_header = 0 }, .diff_cursor_offset = 0, - .diff_scroll = 0, + .diff_scroll = .{ .logical = 0 }, .diff_horizontal_scroll = 0, .sidebar_horizontal_scroll = 0, .search_coordinate = null, @@ -5948,7 +5948,7 @@ test "anchored reload keeps cursor when search query is present" { .selected_target = .{ .diff_file = 0 }, .selected_node = 0, .diff_cursor = .{ .hunk_header = 1 }, - .diff_scroll = 2, + .diff_scroll = .{ .logical = 2 }, }, } }, .allocator = std.testing.allocator, @@ -5980,7 +5980,7 @@ test "anchored reload keeps cursor when search query is present" { }); try std.testing.expectEqual(diff_view_model.BodyCoordinate{ .hunk_header = 1 }, app.pages.changes.viewer.diff_cursor); - try std.testing.expectEqual(@as(usize, 2), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 2), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expect(app.pages.changes.search.match != null); } @@ -5994,7 +5994,7 @@ test "manual reload restores anchor after visible state is cleared" { .selected_target = .{ .diff_file = 1 }, .selected_node = 1, .diff_cursor = .{ .metadata = 0 }, - .diff_scroll = 2, + .diff_scroll = .{ .logical = 2 }, }, } }, .allocator = std.testing.allocator, diff --git a/src/app/pages/changes/reload.zig b/src/app/pages/changes/reload.zig index d67076f..553056f 100644 --- a/src/app/pages/changes/reload.zig +++ b/src/app/pages/changes/reload.zig @@ -5,6 +5,7 @@ //! effects; it deliberately has no `App`, `Ctx`, overlay, or process access. const std = @import("std"); +const diff_surface = @import("../../diff_surface.zig"); const context = @import("../../../context.zig"); const content_fingerprint = @import("../../../content_fingerprint.zig"); const builtin = @import("builtin"); @@ -4084,9 +4085,9 @@ pub const Controller = struct { } if (self.navigation.view().selectedDiffCursorOffset() == null) self.navigation.initializeDiffCursorForSelectedFile(); self.page.viewer.diff_scroll = if (anchor.selection_viewport) |viewport| - self.navigation.view().restoredSelectionViewportScroll(viewport) + self.navigation.view().restoredViewport(viewport) else - anchor.diff_scroll; + .atRow(anchor.diff_scroll.row()); self.page.viewer.diff_horizontal_scroll = anchor.diff_horizontal_scroll; self.page.viewer.sidebar_horizontal_scroll = anchor.sidebar_horizontal_scroll; self.navigation.clampDiffNavigation(); @@ -4167,7 +4168,7 @@ pub const Controller = struct { self.page.staged_hunks.clear(owned_allocator); self.clearDisplayRestore(owned_allocator); } - self.page.viewer.diff_scroll = 0; + self.page.viewer.diff_scroll = .{ .logical = 0 }; self.page.viewer.diff_horizontal_scroll = 0; self.page.viewer.sidebar_horizontal_scroll = 0; self.page.viewer.diff_cursor = .{ .metadata = 0 }; @@ -4728,7 +4729,7 @@ const TestSelectionViewportTop = enum { fn captureTestSelectionViewportTop( controller: Controller, top: TestSelectionViewportTop, -) !selection_action.SelectionViewportAnchor { +) !diff_surface.navigation.ViewportAnchor { const view = controller.navigation.view(); const max_scroll = view.displayedDiffLineCount() -| view.diffVisibleRows(); const raw_scroll = switch (top) { @@ -4737,7 +4738,7 @@ fn captureTestSelectionViewportTop( .source_late => max_scroll / 2, .source_end => max_scroll, }; - controller.page.viewer.diff_scroll = raw_scroll; + controller.page.viewer.diff_scroll = .{ .logical = raw_scroll }; const anchor = controller.navigation.captureSelectionViewportAnchor() orelse return error.ExpectedSelectionViewportAnchor; try std.testing.expectEqual(raw_scroll, anchor.raw_presentation_scroll); @@ -4756,14 +4757,14 @@ fn captureTestSelectionViewportTop( return anchor; } -fn expectParsedTestSelectionViewport(anchor: selection_action.SelectionViewportAnchor) !void { +fn expectParsedTestSelectionViewport(anchor: diff_surface.navigation.ViewportAnchor) !void { switch (anchor.semantic_source) { .parsed => {}, .none, .generated_row => return error.ExpectedParsedSelectionViewportSource, } } -fn expectGeneratedTestSelectionViewport(anchor: selection_action.SelectionViewportAnchor) !void { +fn expectGeneratedTestSelectionViewport(anchor: diff_surface.navigation.ViewportAnchor) !void { switch (anchor.semantic_source) { .generated_row => {}, .none, .parsed => return error.ExpectedGeneratedSelectionViewportSource, @@ -4772,20 +4773,20 @@ fn expectGeneratedTestSelectionViewport(anchor: selection_action.SelectionViewpo fn expectTestSelectionViewportRestored( controller: Controller, - anchor: selection_action.SelectionViewportAnchor, + anchor: diff_surface.navigation.ViewportAnchor, ) !void { - const expected = controller.navigation.view().restoredSelectionViewportScroll(anchor); - try std.testing.expectEqual(expected, controller.page.viewer.diff_scroll); + const expected = controller.navigation.view().restoredViewport(anchor).row(); + try std.testing.expectEqual(expected, controller.page.viewer.diff_scroll.row()); } fn expectTestSelectionViewportRawReuse( controller: Controller, - anchor: selection_action.SelectionViewportAnchor, + anchor: diff_surface.navigation.ViewportAnchor, ) !void { const current = controller.navigation.captureSelectionViewportAnchor() orelse return error.ExpectedRetainedSelectionViewportAnchor; try std.testing.expect(anchor.basis.eql(current.basis)); - try std.testing.expectEqual(anchor.raw_presentation_scroll, controller.page.viewer.diff_scroll); + try std.testing.expectEqual(anchor.raw_presentation_scroll, controller.page.viewer.diff_scroll.row()); } fn testMultiFileProjectionSelectionScope( @@ -4821,7 +4822,7 @@ fn testMultiFileProjectionSelectionScope( defer allocator.free(original_clipboard); controller.navigation.setSelectedDiffFile(1); - var viewport_anchor: ?selection_action.SelectionViewportAnchor = null; + var viewport_anchor: ?diff_surface.navigation.ViewportAnchor = null; if (owner == .target_path and terminal != .cache_hit) { viewport_anchor = try captureTestSelectionViewportTop(controller, switch (terminal) { .normal_ready => .source_start, @@ -4966,7 +4967,7 @@ fn testMultiFileProjectionSelectionScope( if (terminal == .failed or terminal == .failed_static) { try std.testing.expect(controller.navigation.view().displayedChangesBody() == .status); try std.testing.expectEqual(@as(usize, 1), controller.navigation.view().displayedDiffLineCount()); - try std.testing.expectEqual(@as(usize, 0), page.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), page.viewer.diff_scroll.row()); } } } @@ -6939,6 +6940,7 @@ test "source session replacement clears populated projection cache" { defer status_bundle.deinit(); var page: changes_page.ChangesPageState = .{}; defer page.deinit(allocator); + page.viewer.view_options.line_wrap = true; var status_message = @import("../../state.zig").StatusMessage{}; const controller = testController(&page, &status_message, .unstaged); @@ -6950,6 +6952,7 @@ test "source session replacement clears populated projection cache" { try std.testing.expectEqual(@as(u64, 1), page.source_session_revision); try std.testing.expectEqual(@as(usize, 0), page.changes_projection.cacheLen()); try std.testing.expect(!page.changes_projection.cacheHas(.{}, "/repo", "a", .generated_added_file, .unstaged, 0, 0)); + try std.testing.expect(page.viewer.view_options.line_wrap); } fn testPageWithOwnedFileSearchCandidate(allocator: std.mem.Allocator) !changes_page.ChangesPageState { @@ -8208,7 +8211,7 @@ test "ordinary primary exact combined reuse retains active search match and curs controller.navigation.refreshSearchForSelectedFile(allocator); const search_before = page.search.match orelse return error.ExpectedSearchMatch; page.viewer.diff_cursor = .{ .hunk_line = .{ .hunk_index = 2, .line_index = 1 } }; - page.viewer.diff_scroll = 1; + page.viewer.diff_scroll = .{ .logical = 1 }; page.viewer.diff_horizontal_scroll = 2; const search_offset_before = page.search.match_offset; const cursor_before = page.viewer.diff_cursor; @@ -8243,7 +8246,7 @@ test "ordinary primary exact combined reuse retains active search match and curs try std.testing.expect(std.meta.eql(search_before, page.search.match.?)); try std.testing.expectEqual(search_offset_before, page.search.match_offset); try std.testing.expect(std.meta.eql(cursor_before, page.viewer.diff_cursor)); - try std.testing.expectEqual(@as(usize, 1), page.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 1), page.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(usize, 2), page.viewer.diff_horizontal_scroll); } @@ -8356,7 +8359,7 @@ test "ordinary primary staged-only status does not enter final-hunk projection b .selected_target = .{ .diff_file = 0 }, .display_mode = .unified, .diff_cursor = .{ .hunk_line = .{ .hunk_index = 0, .line_index = 2 } }, - .diff_scroll = 2, + .diff_scroll = .{ .logical = 2 }, .diff_horizontal_scroll = 3, }, }; @@ -8386,12 +8389,12 @@ test "ordinary primary staged-only status does not enter final-hunk projection b try page.staged_hunks.addExact(allocator, "/repo", "a", mark_key); try page.search.query.insertSlice("new"); controller.navigation.refreshSearchForSelectedFile(allocator); - page.viewer.diff_scroll = 2; + page.viewer.diff_scroll = .{ .logical = 2 }; page.viewer.diff_horizontal_scroll = 3; const search_before = page.search.match.?; const search_offset_before = page.search.match_offset; const cursor_before = page.viewer.diff_cursor; - const scroll_before = page.viewer.diff_scroll; + const scroll_before = page.viewer.diff_scroll.row(); const horizontal_before = page.viewer.diff_horizontal_scroll; try std.testing.expect(controller.view().projectionTarget() == null); @@ -8413,7 +8416,7 @@ test "ordinary primary staged-only status does not enter final-hunk projection b try std.testing.expect(std.meta.eql(search_before, page.search.match.?)); try std.testing.expectEqual(search_offset_before, page.search.match_offset); try std.testing.expect(std.meta.eql(cursor_before, page.viewer.diff_cursor)); - try std.testing.expectEqual(scroll_before, page.viewer.diff_scroll); + try std.testing.expectEqual(scroll_before, page.viewer.diff_scroll.row()); try std.testing.expectEqual(horizontal_before, page.viewer.diff_horizontal_scroll); } @@ -8425,7 +8428,7 @@ test "final hunk stage retains owned combined presentation with staged-only auth .selected_target = .{ .diff_file = 0 }, .display_mode = .unified, .diff_cursor = .{ .hunk_line = .{ .hunk_index = 2, .line_index = 1 } }, - .diff_scroll = 5, + .diff_scroll = .{ .logical = 5 }, .diff_horizontal_scroll = 4, }, }; @@ -8451,12 +8454,12 @@ test "final hunk stage retains owned combined presentation with staged-only auth // non-empty query without silently starting its first match when the same // presentation crosses into staged-only. try std.testing.expect(page.search.match == null); - page.viewer.diff_scroll = 5; + page.viewer.diff_scroll = .{ .logical = 5 }; page.viewer.diff_horizontal_scroll = 4; const search_before = page.search.match; const search_offset_before = page.search.match_offset; const cursor_before = page.viewer.diff_cursor; - const scroll_before = page.viewer.diff_scroll; + const scroll_before = page.viewer.diff_scroll.row(); const horizontal_before = page.viewer.diff_horizontal_scroll; controller.advanceStatusSnapshotRevision(allocator); @@ -8502,7 +8505,7 @@ test "final hunk stage retains owned combined presentation with staged-only auth try std.testing.expect(std.meta.eql(search_before, page.search.match)); try std.testing.expectEqual(search_offset_before, page.search.match_offset); try std.testing.expect(std.meta.eql(cursor_before, page.viewer.diff_cursor)); - try std.testing.expectEqual(scroll_before, page.viewer.diff_scroll); + try std.testing.expectEqual(scroll_before, page.viewer.diff_scroll.row()); try std.testing.expectEqual(horizontal_before, page.viewer.diff_horizontal_scroll); try std.testing.expectEqualStrings("a", controller.navigation.view().selectedStagePathKey().?); try std.testing.expect(controller.navigation.view().displayedSearchTarget(.unified) != null); @@ -8523,7 +8526,7 @@ test "final hunk stage retains primary presentation with staged-only authority" .selected_target = .{ .diff_file = 0 }, .display_mode = .unified, .diff_cursor = .{ .hunk_line = .{ .hunk_index = 2, .line_index = 1 } }, - .diff_scroll = 6, + .diff_scroll = .{ .logical = 6 }, .diff_horizontal_scroll = 2, }, }; @@ -8577,12 +8580,12 @@ test "final hunk stage retains primary presentation with staged-only authority" try page.search.query.insertSlice("gamma"); controller.navigation.refreshSearchForSelectedFile(allocator); controller.navigation.selectSearchMatch(allocator, .forward); - page.viewer.diff_scroll = 6; + page.viewer.diff_scroll = .{ .logical = 6 }; page.viewer.diff_horizontal_scroll = 2; const search_before = page.search.match.?; const search_offset_before = page.search.match_offset; const cursor_before = page.viewer.diff_cursor; - const scroll_before = page.viewer.diff_scroll; + const scroll_before = page.viewer.diff_scroll.row(); const horizontal_before = page.viewer.diff_horizontal_scroll; controller.advanceStatusSnapshotRevision(allocator); @@ -8635,7 +8638,7 @@ test "final hunk stage retains primary presentation with staged-only authority" try std.testing.expect(std.meta.eql(search_before, page.search.match.?)); try std.testing.expectEqual(search_offset_before, page.search.match_offset); try std.testing.expect(std.meta.eql(cursor_before, page.viewer.diff_cursor)); - try std.testing.expectEqual(scroll_before, page.viewer.diff_scroll); + try std.testing.expectEqual(scroll_before, page.viewer.diff_scroll.row()); try std.testing.expectEqual(horizontal_before, page.viewer.diff_horizontal_scroll); try std.testing.expect(controller.navigation.view().displayedSearchTarget(.unified) != null); try std.testing.expect(page.completed_selection != null); @@ -9138,7 +9141,7 @@ test "one hunk unstage restores combined authority over retained owned presentat .selected_target = .{ .diff_file = 0 }, .display_mode = .unified, .diff_cursor = .{ .hunk_line = .{ .hunk_index = 2, .line_index = 1 } }, - .diff_scroll = 5, + .diff_scroll = .{ .logical = 5 }, .diff_horizontal_scroll = 4, }, }; @@ -9164,10 +9167,10 @@ test "one hunk unstage restores combined authority over retained owned presentat // must not convert the retained query into a new first match while authority changes underneath // the same rendered coordinates. try std.testing.expect(page.search.match == null); - page.viewer.diff_scroll = 5; + page.viewer.diff_scroll = .{ .logical = 5 }; page.viewer.diff_horizontal_scroll = 4; const cursor_before = page.viewer.diff_cursor; - const scroll_before = page.viewer.diff_scroll; + const scroll_before = page.viewer.diff_scroll.row(); const horizontal_before = page.viewer.diff_horizontal_scroll; controller.advanceStatusSnapshotRevision(allocator); @@ -9218,7 +9221,7 @@ test "one hunk unstage restores combined authority over retained owned presentat try std.testing.expect(combined.authority.projection.hunk_action_origins[2] == .unstaged); try std.testing.expect(page.search.match == null); try std.testing.expect(std.meta.eql(cursor_before, page.viewer.diff_cursor)); - try std.testing.expectEqual(scroll_before, page.viewer.diff_scroll); + try std.testing.expectEqual(scroll_before, page.viewer.diff_scroll.row()); try std.testing.expectEqual(horizontal_before, page.viewer.diff_horizontal_scroll); try std.testing.expect(page.completed_selection != null); try expectTestSessionHunkMarks(&page, "/repo", "a", retained_content, &.{ 0, 1 }); @@ -9235,7 +9238,7 @@ test "one hunk unstage restores combined authority over retained primary present .selected_target = .{ .diff_file = 0 }, .display_mode = .unified, .diff_cursor = .{ .hunk_line = .{ .hunk_index = 2, .line_index = 1 } }, - .diff_scroll = 6, + .diff_scroll = .{ .logical = 6 }, .diff_horizontal_scroll = 2, }, }; @@ -9264,12 +9267,12 @@ test "one hunk unstage restores combined authority over retained primary present try page.search.query.insertSlice("gamma"); controller.navigation.refreshSearchForSelectedFile(allocator); controller.navigation.selectSearchMatch(allocator, .forward); - page.viewer.diff_scroll = 6; + page.viewer.diff_scroll = .{ .logical = 6 }; page.viewer.diff_horizontal_scroll = 2; const search_before = page.search.match.?; const search_offset_before = page.search.match_offset; const cursor_before = page.viewer.diff_cursor; - const scroll_before = page.viewer.diff_scroll; + const scroll_before = page.viewer.diff_scroll.row(); const horizontal_before = page.viewer.diff_horizontal_scroll; controller.advanceStatusSnapshotRevision(allocator); @@ -9316,7 +9319,7 @@ test "one hunk unstage restores combined authority over retained primary present try std.testing.expect(std.meta.eql(search_before, page.search.match.?)); try std.testing.expectEqual(search_offset_before, page.search.match_offset); try std.testing.expect(std.meta.eql(cursor_before, page.viewer.diff_cursor)); - try std.testing.expectEqual(scroll_before, page.viewer.diff_scroll); + try std.testing.expectEqual(scroll_before, page.viewer.diff_scroll.row()); try std.testing.expectEqual(horizontal_before, page.viewer.diff_horizontal_scroll); try std.testing.expect(page.completed_selection != null); const accepted_clipboard = try page.completed_selection.?.clipboardText(allocator); @@ -9332,7 +9335,7 @@ test "final staged hunk unstage returns owned combined display to exact ordinary .selected_target = .{ .diff_file = 0 }, .display_mode = .unified, .diff_cursor = .{ .hunk_line = .{ .hunk_index = 2, .line_index = 1 } }, - .diff_scroll = 5, + .diff_scroll = .{ .logical = 5 }, .diff_horizontal_scroll = 3, }, }; @@ -9381,7 +9384,7 @@ test "final staged hunk unstage returns owned combined display to exact ordinary const cursor_offset_after = controller.navigation.view().selectedDiffCursorOffset() orelse return error.ExpectedRestoredPrimaryCursorOffset; try std.testing.expect(cursor_offset_after < ordinary_line_count); - try std.testing.expect(page.viewer.diff_scroll < ordinary_line_count); + try std.testing.expect(page.viewer.diff_scroll.row() < ordinary_line_count); try std.testing.expect(page.viewer.diff_horizontal_scroll <= horizontal_before); try std.testing.expect(page.search.match != null); try std.testing.expect(page.search.match_offset != null); @@ -9430,7 +9433,7 @@ test "owned combined to folded primary remaps hidden cursor and clamps scroll" { try std.testing.expect(unfolded_line_count > folded_line_count); const unfolded_cursor_offset = controller.navigation.view().selectedDiffCursorOffset() orelse return error.ExpectedCombinedCursorOffset; - page.viewer.diff_scroll = unfolded_line_count; + page.viewer.diff_scroll = .{ .logical = unfolded_line_count }; controller.advanceStatusSnapshotRevision(allocator); var unstaged_status = try git_status.StatusBundle.parseOwned(allocator, " M a\x00"); @@ -9453,7 +9456,7 @@ test "owned combined to folded primary remaps hidden cursor and clamps scroll" { .hunk_line => |line| try std.testing.expect(line.hunk_index != 2), else => {}, } - try std.testing.expect(page.viewer.diff_scroll < ordinary_line_count); + try std.testing.expect(page.viewer.diff_scroll.row() < ordinary_line_count); } test "final staged hunk unstage removes primary combined authority without replacing primary" { @@ -9464,7 +9467,7 @@ test "final staged hunk unstage removes primary combined authority without repla .selected_target = .{ .diff_file = 0 }, .display_mode = .unified, .diff_cursor = .{ .hunk_line = .{ .hunk_index = 1, .line_index = 1 } }, - .diff_scroll = 4, + .diff_scroll = .{ .logical = 4 }, .diff_horizontal_scroll = 2, }, }; @@ -9493,12 +9496,12 @@ test "final staged hunk unstage removes primary combined authority without repla try page.search.query.insertSlice("beta"); controller.navigation.refreshSearchForSelectedFile(allocator); controller.navigation.selectSearchMatch(allocator, .forward); - page.viewer.diff_scroll = 4; + page.viewer.diff_scroll = .{ .logical = 4 }; page.viewer.diff_horizontal_scroll = 2; const search_before = page.search.match.?; const search_offset_before = page.search.match_offset; const cursor_before = page.viewer.diff_cursor; - const scroll_before = page.viewer.diff_scroll; + const scroll_before = page.viewer.diff_scroll.row(); const horizontal_before = page.viewer.diff_horizontal_scroll; var candidate = try testCombinedReuseCandidate( @@ -9544,7 +9547,7 @@ test "final staged hunk unstage removes primary combined authority without repla try std.testing.expect(std.meta.eql(search_before, page.search.match.?)); try std.testing.expectEqual(search_offset_before, page.search.match_offset); try std.testing.expect(std.meta.eql(cursor_before, page.viewer.diff_cursor)); - try std.testing.expectEqual(scroll_before, page.viewer.diff_scroll); + try std.testing.expectEqual(scroll_before, page.viewer.diff_scroll.row()); try std.testing.expectEqual(horizontal_before, page.viewer.diff_horizontal_scroll); const accepted_clipboard = try page.completed_selection.?.clipboardText(allocator); defer allocator.free(accepted_clipboard); @@ -10594,7 +10597,7 @@ test "projection candidate survives exact rebuild and clears on changed content changed_owned = false; try std.testing.expect(page.completed_selection == null); try expectTestSelectionViewportRestored(controller, changed_viewport); - try std.testing.expectEqual(@as(usize, 0), page.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), page.viewer.diff_scroll.row()); } test "deferred source terminals consume blocked and accepted ownership" { diff --git a/src/app/pages/changes/update.zig b/src/app/pages/changes/update.zig index 23ae97d..ab51faf 100644 --- a/src/app/pages/changes/update.zig +++ b/src/app/pages/changes/update.zig @@ -577,7 +577,7 @@ test "changes mouse release retains candidate until explicit copy or clear" { } const retained_token = page.completed_selection.?.token; - page.viewer.diff_scroll = 0; + page.viewer.diff_scroll = .{ .logical = 0 }; page.viewer.diff_cursor = .{ .hunk_header = 0 }; const raw = controller.navigation.view().rawDiffPaneGeometry().?; const content_gutter = raw.width - diff_surface_navigation.contentWidth(raw.width); diff --git a/src/app/pages/changes/view.zig b/src/app/pages/changes/view.zig index 0dd3895..31b26a1 100644 --- a/src/app/pages/changes/view.zig +++ b/src/app/pages/changes/view.zig @@ -338,9 +338,11 @@ fn projectedBodyRenderArgs( .requested_mode = app.page.viewer.display_mode, .display_mode_toggle_key = display_mode_toggle_key, .scroll = app.navigation.renderDiffScroll(), + .wrap_start = app.navigation.renderWrapStart(), .horizontal_scroll = app.page.viewer.diff_horizontal_scroll, .pane_active = active, .line_numbers = app.page.viewer.view_options.line_numbers, + .line_wrap = app.page.viewer.view_options.line_wrap, .highlighted_hunk = app.selectedHunkIndex(), .cursor_offset = app.navigation.renderDiffCursorOffset(), .palette = app.theme, @@ -378,6 +380,7 @@ fn drawStatusBody(surface: *chasen.Surface, path: []const u8, message: []const u .horizontal_scroll = 0, .pane_active = active, .line_numbers = true, + .line_wrap = false, .highlighted_hunk = null, .cursor_offset = null, .palette = palette, @@ -513,10 +516,10 @@ test "changes reviewed and file filters retain combined status" { } pub fn drawSearchMatchMarker(app: Context, surface: *chasen.Surface) void { - return diff_surface_view.drawSearchMatchMarker( + return diff_surface_view.drawSearchMatchMarkerRow( surface, - app.page.readSurface(app.source, app.navigation.layout), app.theme, + if (app.page.search.match_offset) |offset| app.navigation.screenRowForOffset(offset) else null, ); } @@ -1377,7 +1380,7 @@ test "reviewed sidebar marker and visible search marker are Changes view concern .lines = 0, }), .search = .{ .match_offset = 4 }, - .viewer = .{ .diff_scroll = 3 }, + .viewer = .{ .diff_scroll = .{ .logical = 3 } }, }; var ts: chasen.testing.TestSurface = undefined; try ts.init(34, 8); diff --git a/src/app/pages/committed_diff.zig b/src/app/pages/committed_diff.zig index 9fcf9ce..0ce59f4 100644 --- a/src/app/pages/committed_diff.zig +++ b/src/app/pages/committed_diff.zig @@ -236,7 +236,7 @@ pub const State = struct { } pub fn resetAcceptedDisplayNavigation(self: *State) void { - self.viewer.diff_scroll = 0; + self.viewer.diff_scroll = .{ .logical = 0 }; self.viewer.diff_horizontal_scroll = 0; self.viewer.sidebar_horizontal_scroll = 0; self.viewer.diff_cursor = .{ .metadata = 0 }; @@ -523,10 +523,10 @@ test "committed diff state owns shared navigation without page target authority" .current_target = null, .live_drag_deferred_source = false, }); - surface.viewer.diff_scroll = 7; + surface.viewer.diff_scroll = .{ .logical = 7 }; surface.search.mode = true; - try std.testing.expectEqual(@as(usize, 7), state.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 7), state.viewer.diff_scroll.row()); try std.testing.expect(state.search.mode); try std.testing.expect(surface.viewer == &state.viewer); } @@ -602,6 +602,8 @@ test "committed diff exact reload preserves folds selection pin and indexes for for (cases) |case| { var state: State = .{}; defer state.deinit(allocator); + try std.testing.expect(!state.viewer.view_options.line_wrap); + state.viewer.view_options.line_wrap = true; var initial: app_load.CommittedDiffBundle = .{ .loaded = try app_load.buildLoadedBundle(allocator, patch) }; defer initial.deinit(); @@ -687,6 +689,7 @@ test "committed diff exact reload preserves folds selection pin and indexes for const clipboard_after = try state.completed_selection.?.clipboardText(allocator); defer allocator.free(clipboard_after); try std.testing.expectEqualStrings(clipboard_before, clipboard_after); + try std.testing.expect(state.viewer.view_options.line_wrap); transferred.setHunkFolded(0, 1, false); const unfolded_unified_rows = transferred.renderedLineIndex(0, .unified).lineCount(); @@ -750,5 +753,8 @@ test "committed diff exact reload preserves folds selection pin and indexes for ); try std.testing.expect(state.completed_selection == null); try std.testing.expect(state.pinned_selection_basis == null); + try std.testing.expect(state.viewer.view_options.line_wrap); + state.deinit(allocator); + try std.testing.expect(!state.viewer.view_options.line_wrap); } } diff --git a/src/app/pages/committed_diff/coordinator.zig b/src/app/pages/committed_diff/coordinator.zig index 130be95..f530799 100644 --- a/src/app/pages/committed_diff/coordinator.zig +++ b/src/app/pages/committed_diff/coordinator.zig @@ -55,7 +55,7 @@ pub const Controller = struct { pub fn initializeAcceptedBody( self: Controller, allocator: std.mem.Allocator, - transferred_viewport: ?diff_surface.selection_action.SelectionViewportAnchor, + transferred_viewport: ?diff_surface.navigation.ViewportAnchor, ) void { var adapter = self.navigation.updateAdapter(); const cleanup = adapter.selectionMappingCleanup(allocator); @@ -67,7 +67,10 @@ pub const Controller = struct { } body.clampDiffNavigation(); if (transferred_viewport) |anchor| { - if (self.navigation.diff.completed_selection != null) body.restoreSelectionViewportAnchor(anchor); + if (self.navigation.diff.completed_selection != null or body.view().lineWrapActive()) { + body.restoreSelectionViewportAnchor(anchor); + body.clampDiffNavigationKeepingHunkVisible(); + } } body.refreshSearchForSelectedFile(cleanup); body.controller.rebuildFileSearchProjection(allocator); diff --git a/src/app/pages/committed_diff/input.zig b/src/app/pages/committed_diff/input.zig index ca5ea1d..31c78b1 100644 --- a/src/app/pages/committed_diff/input.zig +++ b/src/app/pages/committed_diff/input.zig @@ -88,6 +88,7 @@ fn publicActionToMsg(action: keymap.PublicAction, diff_focused: bool) ?Msg { .file_search => shared(.enter_file_search), .toggle_display_mode => shared(.toggle_display_mode), .toggle_line_numbers => shared(.toggle_line_numbers), + .toggle_line_wrap => shared(.toggle_line_wrap), .toggle_sidebar => shared(.toggle_sidebar_visibility), .decrease_sidebar_width => shared(.decrease_sidebar_width), .increase_sidebar_width => shared(.increase_sidebar_width), diff --git a/src/app/pages/committed_diff/navigation.zig b/src/app/pages/committed_diff/navigation.zig index d162c39..1cce101 100644 --- a/src/app/pages/committed_diff/navigation.zig +++ b/src/app/pages/committed_diff/navigation.zig @@ -397,7 +397,7 @@ test "search unfold mapping cleanup clears retained selection and pin for Compar ); state.viewer.selected_target = .{ .diff_file = 0 }; state.viewer.diff_cursor = .{ .hunk_line = .{ .hunk_index = 0, .line_index = 0 } }; - state.viewer.diff_scroll = 1; + state.viewer.diff_scroll = .{ .logical = 1 }; const loaded = switch (state.load.state) { .loaded => |*session| &session.loaded, else => return error.ExpectedLoadedDiff, @@ -451,7 +451,7 @@ test "search unfold mapping cleanup clears retained selection and pin for Compar try std.testing.expect(state.completed_selection == null); try std.testing.expect(state.pinned_selection_basis == null); try std.testing.expectEqual(revision + 1, state.selection_layout_revision); - try std.testing.expectEqual(body.view().restoreSelectionViewportAnchor(viewport), state.viewer.diff_scroll); + try std.testing.expectEqual(body.view().restoreSelectionViewportAnchor(viewport), state.viewer.diff_scroll.row()); state.completed_selection = try diff_surface.selection.buildParsedFolded( allocator, diff --git a/src/app/pages/compare.zig b/src/app/pages/compare.zig index c86535b..b683ab9 100644 --- a/src/app/pages/compare.zig +++ b/src/app/pages/compare.zig @@ -555,10 +555,10 @@ test "Compare activation and retained diff state are independent" { var state: ComparePageState = .{}; defer state.deinit(allocator); const first = state.activate(9); - state.diff.viewer.diff_scroll = 7; + state.diff.viewer.diff_scroll = .{ .logical = 7 }; state.deactivate(); const second = state.activate(9); try std.testing.expect(first != second); - try std.testing.expectEqual(@as(usize, 7), state.diff.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 7), state.diff.viewer.diff_scroll.row()); try std.testing.expectEqual(page.Id.compare, state.activation.currentIdentity().?.origin); } diff --git a/src/app/pages/history.zig b/src/app/pages/history.zig index 4231087..d3a0aea 100644 --- a/src/app/pages/history.zig +++ b/src/app/pages/history.zig @@ -1540,7 +1540,7 @@ test "History picker revalidates accepted context and repository replacement fen state.diff.viewer = .{ .selected_node = 3, .focus = .diff, - .diff_scroll = 7, + .diff_scroll = .{ .logical = 7 }, .display_mode = .unified, }; const accepted_origin = state.accepted.?.origin.branch.ptr; @@ -1552,7 +1552,7 @@ test "History picker revalidates accepted context and repository replacement fen try std.testing.expectEqual(CurrentView.diff, state.current_view); try std.testing.expectEqual(accepted_origin, state.accepted.?.origin.branch.ptr); try std.testing.expectEqual(@as(usize, 3), state.diff.viewer.selected_node); - try std.testing.expectEqual(@as(usize, 7), state.diff.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 7), state.diff.viewer.diff_scroll.row()); try std.testing.expect(state.diff.viewer.display_mode == .unified); state.cancelLoad(); @@ -1964,11 +1964,11 @@ test "History branch switch resets picker only on success and preserves accepted try std.testing.expectEqual(case.policy, state.nextRequest().?.initial); // The accepted range is an immutable selection, separate from the new picker draft. state.current_view = .diff; - state.diff.viewer.diff_scroll = 7; + state.diff.viewer.diff_scroll = .{ .logical = 7 }; state.branchSwitchFinished(allocator, case.success); try std.testing.expectEqual(CurrentView.diff, state.current_view); try std.testing.expectEqualDeep(accepted_request, state.accepted.?.request); try std.testing.expectEqual(@as(usize, 2), state.accepted.?.request.intent.commitCount()); - try std.testing.expectEqual(@as(usize, 7), state.diff.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 7), state.diff.viewer.diff_scroll.row()); } } diff --git a/src/app/pages/repository.zig b/src/app/pages/repository.zig index c87754b..d691910 100644 --- a/src/app/pages/repository.zig +++ b/src/app/pages/repository.zig @@ -5,7 +5,6 @@ const std = @import("std"); const chasen = @import("chasen"); -const text_projection = @import("chasen_ui").text_projection; const keymap = @import("keymap"); const app_direction = @import("../direction.zig"); const git_ops = @import("../git_ops.zig"); @@ -42,10 +41,10 @@ const repository_navigation = @import("repository/navigation.zig"); const repository_selection = @import("repository/selection.zig"); const repository_source_header = @import("repository/source_header.zig"); const repository_source_geometry = @import("repository/source_geometry.zig"); +const repository_source_layout = @import("repository/source_layout.zig"); const repository_tree_projection = @import("repository/tree_projection.zig"); -const repository_tab_width: usize = 4; -pub const SelectionViewportAnchor = selection_action.ViewportAnchor(usize); +pub const SelectionViewportAnchor = repository_model.SourcePosition; pub const SourceSelectionPresentation = struct { pub const Owner = enum { active_keyboard, completed }; @@ -185,6 +184,7 @@ pub const Msg = union(enum) { source_last, toggle_changed_filter, toggle_line_numbers, + toggle_line_wrap, enter_source_search, cancel_source_search, submit_source_search, @@ -660,7 +660,7 @@ pub const RepositoryPageState = struct { // The next frame clamps this against the real viewport; // keeping the cursor as the provisional top row makes the // accepted location visible even before that geometry pass. - self.viewer.source_vertical_scroll = line_index; + self.viewer.source_vertical_scroll = .{ .line = line_index }; } else if (accepted_generation != null) { // A newly accepted target opens its source. Reusing an // already displayed path with no line is intentionally a @@ -1426,9 +1426,9 @@ pub const RepositoryPageState = struct { self.reconcileNoSourceFocus(); if (viewport_anchor) |anchor| if (self.currentSource()) |document| { // Coordinator clamps against the real frame geometry after this - // allocation-free semantic restore. Zero visible rows deliberately + // allocation-free semantic restore. Absent geometry deliberately // avoid inventing a terminal height inside the page owner. - self.restoreSourceViewportAnchor(anchor, document, 0); + self.restoreSourceViewportAnchor(anchor, document, null); }; self.source_search.clear(); self.needs_syntax_request = source_syntax_runtime.enabled and self.currentSource() != null; @@ -1775,6 +1775,7 @@ pub const RepositoryPageState = struct { .decrease_tree_width, .increase_tree_width, .toggle_line_numbers, + .toggle_line_wrap, .enter_source_search, .cancel_source_search, .clear_source_search, @@ -1948,6 +1949,11 @@ pub const RepositoryPageState = struct { self.sourceGeometry(body_size, document), ); }, + .toggle_line_wrap => { + self.viewer.line_wrap = !self.viewer.line_wrap; + self.viewer.source_vertical_scroll.intra = null; + if (source) |document| repository_navigation.clampSource(&self.viewer, document, source_geometry.?); + }, .enter_source_search => if (source != null) { if (self.selection_owner != .none or self.completed_selection != null) { self.clearLiveAndCompletedSelectionPreservingViewport(allocator, body_size); @@ -2019,7 +2025,7 @@ pub const RepositoryPageState = struct { self.clearLiveSelection(); if (document) |value| if (viewport_anchor) |anchor| { const geometry = self.sourceGeometry(body_size, value); - self.restoreSourceViewportAnchor(anchor, value, geometry.visible_source_rows); + self.restoreSourceViewportAnchor(anchor, value, geometry); }; }; if (viewer_before) |before| { @@ -2067,7 +2073,7 @@ pub const RepositoryPageState = struct { // This page-level authority boundary has no frame geometry. Match // document replacement by preserving the semantic top without // inventing a terminal height; the coordinator clamps next frame. - self.restoreSourceViewportAnchor(anchor, document, 0); + self.restoreSourceViewportAnchor(anchor, document, null); }; } @@ -2610,7 +2616,7 @@ pub const RepositoryPageState = struct { self.clearLiveSelection(); if (viewport_anchor) |anchor| if (document) |value| { const geometry = self.sourceGeometry(body_size, value); - self.restoreSourceViewportAnchor(anchor, value, geometry.visible_source_rows); + self.restoreSourceViewportAnchor(anchor, value, geometry); }; } @@ -2657,46 +2663,25 @@ pub const RepositoryPageState = struct { return .{ .range = completed.range, .line_count = completed.line_count, .owner = .completed }; } - fn sourceViewportBasis( - self: *const RepositoryPageState, - document: *const source_document.Document, - ) selection_action.ViewportBasis { - return .{ - .layout_revision = self.source_revision, - .source_rows = document.rowCount(), - .presentation_rows = document.rowCount(), - }; - } - fn captureSourceViewportAnchor( self: *const RepositoryPageState, document: *const source_document.Document, ) SelectionViewportAnchor { - const position = selection_action.captureAnchorPosition( - document.rowCount(), - self.viewer.source_vertical_scroll, - ); - return .{ - .semantic_source = position.source_offset_fallback, - .source_offset_fallback = position.source_offset_fallback, - .raw_presentation_scroll = self.viewer.source_vertical_scroll, - .basis = self.sourceViewportBasis(document), - }; + return self.viewer.source_vertical_scroll.admitted(document); } fn restoreSourceViewportAnchor( self: *RepositoryPageState, anchor: SelectionViewportAnchor, document: *const source_document.Document, - visible_rows: usize, + geometry: ?repository_source_geometry.SourceGeometry, ) void { - self.viewer.source_vertical_scroll = selection_action.restoreViewportAnchor( - anchor, - self.sourceViewportBasis(document), - @min(anchor.semantic_source, document.rowCount() - 1), - @min(anchor.source_offset_fallback, document.rowCount() - 1), - visible_rows, - ); + var position = anchor.admitted(document); + if (!self.viewer.line_wrap) position.intra = null; + self.viewer.source_vertical_scroll = if (geometry) |pane| + repository_source_layout.Layout.init(document, self.viewer, pane).clamp(position, pane.visible_source_rows) + else + position; } pub fn captureSelectionViewportAnchor( @@ -2717,7 +2702,7 @@ pub const RepositoryPageState = struct { self.clampForBodySize(body_size); const document = self.currentSource() orelse return; const geometry = self.sourceGeometry(body_size, document); - self.restoreSourceViewportAnchor(anchor, document, geometry.visible_source_rows); + self.restoreSourceViewportAnchor(anchor, document, geometry); } fn clearCompletedSelectionPreservingViewport( @@ -2730,7 +2715,7 @@ pub const RepositoryPageState = struct { self.clearCompletedSelection(allocator); if (document) |value| if (anchor) |captured| { const geometry = self.sourceGeometry(body_size, value); - self.restoreSourceViewportAnchor(captured, value, geometry.visible_source_rows); + self.restoreSourceViewportAnchor(captured, value, geometry); }; } @@ -2745,7 +2730,7 @@ pub const RepositoryPageState = struct { self.clearCompletedSelection(allocator); if (document) |value| if (anchor) |captured| { const geometry = self.sourceGeometry(body_size, value); - self.restoreSourceViewportAnchor(captured, value, geometry.visible_source_rows); + self.restoreSourceViewportAnchor(captured, value, geometry); }; } @@ -2840,7 +2825,7 @@ pub const RepositoryPageState = struct { } if (document) |value| if (viewport_anchor) |anchor| { const geometry = self.sourceGeometry(body_size, value); - self.restoreSourceViewportAnchor(anchor, value, geometry.visible_source_rows); + self.restoreSourceViewportAnchor(anchor, value, geometry); }; self.status.set("{s}", .{message}); } @@ -2955,7 +2940,7 @@ pub const RepositoryPageState = struct { self.selection_owner = .{ .source_header = .{ .identity = identity } }; if (viewport_anchor) |anchor| if (document) |value| { const geometry = self.sourceGeometry(body_size, value); - self.restoreSourceViewportAnchor(anchor, value, geometry.visible_source_rows); + self.restoreSourceViewportAnchor(anchor, value, geometry); }; } @@ -3018,16 +3003,13 @@ pub const RepositoryPageState = struct { } fn pointAtTextCell( - document: *const source_document.Document, - line_index: usize, + row: repository_source_layout.Row, horizontal_scroll: usize, viewport_cell: usize, ) ?repository_selection.Point { - const line = document.lineBody(line_index) orelse return null; - const projection = text_projection.Projection.init(line, .{ .tab_width = repository_tab_width }) catch return null; - return switch (projection.hitViewportCell(horizontal_scroll, viewport_cell)) { - .token => |token| repository_selection.pointFromToken(line_index, token), - .boundary => |boundary| repository_selection.pointFromBoundary(line_index, boundary.byte_offset), + return switch (row.hit(horizontal_scroll, viewport_cell) orelse return null) { + .token => |token| repository_selection.pointFromToken(row.line, token), + .boundary => |boundary| repository_selection.pointFromBoundary(row.line, boundary.byte_offset), }; } @@ -3035,11 +3017,12 @@ pub const RepositoryPageState = struct { if (self.source_search.mode or self.file_search.mode) return; const document = self.acceptedCurrentSourceForSelection() orelse return; const geometry = self.sourceGeometry(body_size, document); - const line_index = geometry.contentLineAt( - point.row, + const row = repository_source_layout.Layout.init(document, self.viewer, geometry).contentRowAt( + geometry, self.viewer.source_vertical_scroll, - document, + point.row, ) orelse return; + const line_index = row.line; const region = geometry.regionAt(point.col) orelse return; const token = self.currentContentToken() orelse return; const mode: repository_selection.Mode = switch (region) { @@ -3050,8 +3033,7 @@ pub const RepositoryPageState = struct { const logical_point = switch (mode) { .line => repository_selection.pointFromLine(line_index), .character => pointAtTextCell( - document, - line_index, + row, self.viewer.source_horizontal_scroll, @as(usize, point.col - geometry.text_col), ) orelse return, @@ -3069,7 +3051,7 @@ pub const RepositoryPageState = struct { .{ .col = point.col, .row = point.row }, ) }; if (viewport_anchor) |anchor| { - self.restoreSourceViewportAnchor(anchor, document, geometry.visible_source_rows); + self.restoreSourceViewportAnchor(anchor, document, geometry); } } @@ -3089,20 +3071,20 @@ pub const RepositoryPageState = struct { return; } const geometry = self.sourceGeometry(body_size, document); - const line_index = geometry.contentLineAt( - local.row, + const row = repository_source_layout.Layout.init(document, self.viewer, geometry).contentRowAt( + geometry, self.viewer.source_vertical_scroll, - document, + local.row, ) orelse return; + const line_index = row.line; const region = geometry.regionAt(local.col) orelse return; const logical_point: repository_selection.Point = switch (live.mode) { .line => repository_selection.pointFromLine(line_index), .character => switch (region) { - .gutter, .line_number => repository_selection.pointFromBoundary(line_index, 0), + .gutter, .line_number => repository_selection.pointFromBoundary(line_index, row.leadingByte()), .separator => return, .text => pointAtTextCell( - document, - line_index, + row, self.viewer.source_horizontal_scroll, @as(usize, local.col - geometry.text_col), ) orelse return, @@ -3143,7 +3125,7 @@ pub const RepositoryPageState = struct { if (step.direction == .up) -1 else 1, geometry, ); - if (self.viewer.source_vertical_scroll == old_scroll) return .content_edge; + if (std.meta.eql(self.viewer.source_vertical_scroll, old_scroll)) return .content_edge; self.dragSourceSelection(.{ .col = step.endpoint.col, @@ -3228,7 +3210,7 @@ pub const RepositoryPageState = struct { candidate = undefined; self.cancelMouseOwner(); const geometry = self.sourceGeometry(body_size, admitted_document); - self.restoreSourceViewportAnchor(anchor, admitted_document, geometry.visible_source_rows); + self.restoreSourceViewportAnchor(anchor, admitted_document, geometry); self.status.clear(); return null; } @@ -3354,7 +3336,7 @@ pub const RepositoryPageState = struct { owned.deinit(allocator); } const geometry = self.sourceGeometry(body_size, admitted_document); - self.restoreSourceViewportAnchor(viewport_anchor, admitted_document, geometry.visible_source_rows); + self.restoreSourceViewportAnchor(viewport_anchor, admitted_document, geometry); return self.completed_selection.?.clipboardText(allocator) catch error.OutOfMemory; } @@ -3409,6 +3391,7 @@ fn navigationDismissesIncoming(msg: Msg) bool { .previous_file, .next_file, .toggle_line_numbers, + .toggle_line_wrap, .toggle_tree_visibility, .decrease_tree_width, .increase_tree_width, @@ -4262,7 +4245,7 @@ test "repository source line jump is 1 based bounded centered and target fenced" try std.testing.expect(state.applySourceLineCommand(target, 8, size) == .moved); try std.testing.expectEqual(@as(usize, 7), state.viewer.source_cursor); try std.testing.expectEqual(@as(usize, 3), state.viewer.source_horizontal_scroll); - try std.testing.expect(state.viewer.source_vertical_scroll > 0); + try std.testing.expect(state.viewer.source_vertical_scroll.line > 0); const before = state.viewer; const out_of_range = state.applySourceLineCommand(target, 99, size); @@ -4634,10 +4617,10 @@ test "repository keyboard line selection document revalidation clears borrow acr state.viewer.focus = .source; state.viewer.source_cursor = 2; _ = state.applyNavigation(allocator, .begin_keyboard_line_selection, size); - state.viewer.source_vertical_scroll = 5; + state.viewer.source_vertical_scroll.line = 5; try std.testing.expect(state.activeBorrowedSourceRange()); const viewport_before = state.captureSourceViewportAnchor(state.currentSource().?); - try std.testing.expectEqual(@as(usize, 5), viewport_before.semantic_source); + try std.testing.expectEqual(@as(usize, 5), viewport_before.line); const generation: u64 = if (fingerprint_mismatch) 42 else 41; state.change_map_generation = generation; @@ -4676,7 +4659,7 @@ test "repository keyboard line selection document revalidation clears borrow acr try std.testing.expect(state.needs_document_revalidation); try std.testing.expectEqualStrings("zero", state.completed_selection.?.text); const viewport_after = state.captureSourceViewportAnchor(state.currentSource().?); - try std.testing.expectEqual(viewport_before.semantic_source, viewport_after.semantic_source); + try std.testing.expectEqual(viewport_before.line, viewport_after.line); const pending_context = state.inputContext(.{}); try std.testing.expect(pending_context.selection_owner == .none); @@ -4772,9 +4755,9 @@ test "repository keyboard line selection document revalidation clears borrow acr state.viewer.focus = .source; state.viewer.source_cursor = 2; _ = state.applyNavigation(allocator, .begin_keyboard_line_selection, size); - state.viewer.source_vertical_scroll = 5; + state.viewer.source_vertical_scroll.line = 5; const viewport_before = state.captureSourceViewportAnchor(state.currentSource().?); - try std.testing.expectEqual(@as(usize, 5), viewport_before.semantic_source); + try std.testing.expectEqual(@as(usize, 5), viewport_before.line); const generation: u64 = if (fingerprint_mismatch) 52 else 51; state.change_map_generation = generation; @@ -4807,7 +4790,7 @@ test "repository keyboard line selection document revalidation clears borrow acr try std.testing.expect(!state.activeBorrowedSourceRange()); try std.testing.expect(state.completed_selection == null); const viewport_after = state.captureSourceViewportAnchor(state.currentSource().?); - try std.testing.expectEqual(viewport_before.semantic_source, viewport_after.semantic_source); + try std.testing.expectEqual(viewport_before.line, viewport_after.line); } } @@ -4856,10 +4839,10 @@ test "repository mouse and header owners keep prior candidate until all-owner cl replaced.viewer.source_cursor = 2; _ = replaced.applyNavigation(allocator, .begin_keyboard_line_selection, size); const selection_geometry = replaced.sourceGeometry(size, replaced.currentSource().?); - replaced.viewer.source_vertical_scroll = replaced.currentSource().?.rowCount() -| + replaced.viewer.source_vertical_scroll.line = replaced.currentSource().?.rowCount() -| selection_geometry.visible_source_rows; const viewport_before = replaced.captureSourceViewportAnchor(replaced.currentSource().?); - try std.testing.expectEqual(@as(usize, 4), viewport_before.semantic_source); + try std.testing.expectEqual(@as(usize, 4), viewport_before.line); if (header_press) { const page_layout = repository_layout.bodyLayout(size, replaced.viewer.tree_width, replaced.viewer.tree_hidden); @@ -4874,11 +4857,12 @@ test "repository mouse and header owners keep prior candidate until all-owner cl try std.testing.expect(replaced.selection_owner.activeSourceHeader() != null); } else { const geometry = replaced.sourceGeometry(size, replaced.currentSource().?); - const pressed_line = geometry.contentLineAt( - geometry.body_first_row, + const pressed_row = repository_source_layout.Layout.init(replaced.currentSource().?, replaced.viewer, geometry).contentRowAt( + geometry, replaced.viewer.source_vertical_scroll, - replaced.currentSource().?, + geometry.body_first_row, ) orelse return error.ExpectedSourceLine; + const pressed_line = pressed_row.line; try std.testing.expectEqual(@as(usize, 4), pressed_line); _ = replaced.applyNavigation(allocator, .{ .mouse_source_press = .{ .col = geometry.text_col, @@ -4891,7 +4875,7 @@ test "repository mouse and header owners keep prior candidate until all-owner cl try std.testing.expectEqual(retain_prior, replaced.completed_selection != null); if (retain_prior) try std.testing.expectEqualStrings("zero", replaced.completed_selection.?.text); const viewport_after = replaced.captureSourceViewportAnchor(replaced.currentSource().?); - try std.testing.expectEqual(viewport_before.semantic_source, viewport_after.semantic_source); + try std.testing.expectEqual(viewport_before.line, viewport_after.line); }; } @@ -6498,7 +6482,7 @@ test "repository page owns source focus navigation search and mouse geometry" { try std.testing.expectEqual(@as(usize, 2), state.viewer.source_cursor); state.viewer.source_cursor = 99; - state.viewer.source_vertical_scroll = 99; + state.viewer.source_vertical_scroll.line = 99; state.viewer.source_horizontal_scroll = 99; state.clampForBodySize(size); try std.testing.expectEqual(@as(usize, 3), state.viewer.source_cursor); @@ -6536,29 +6520,29 @@ test "repository source comfort page routes wheel page search boundaries and mou state.viewer.focus = .source; state.viewer.source_cursor = 5; - state.viewer.source_vertical_scroll = 5; + state.viewer.source_vertical_scroll.line = 5; _ = state.applyNavigation(allocator, .mouse_source_wheel_down, size); try std.testing.expectEqual(repository_model.Focus.source, state.viewer.focus); - try std.testing.expectEqual(@as(usize, 6), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 6), state.viewer.source_vertical_scroll.line); try std.testing.expectEqual(@as(usize, 6), state.viewer.source_cursor); _ = state.applyNavigation(allocator, .wheel_down, size); try std.testing.expectEqual(repository_model.Focus.tree, state.viewer.focus); - try std.testing.expectEqual(@as(usize, 6), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 6), state.viewer.source_vertical_scroll.line); try std.testing.expectEqual(@as(usize, 6), state.viewer.source_cursor); state.viewer.focus = .source; state.viewer.source_cursor = 5; - state.viewer.source_vertical_scroll = 5; + state.viewer.source_vertical_scroll.line = 5; _ = state.applyNavigation(allocator, .page_down, size); try std.testing.expectEqual(@as(usize, 8), state.viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 7), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 7), state.viewer.source_vertical_scroll.line); state.viewer.source_cursor = 5; - state.viewer.source_vertical_scroll = 5; + state.viewer.source_vertical_scroll.line = 5; _ = state.applyNavigation(allocator, .half_page_down, size); try std.testing.expectEqual(@as(usize, 6), state.viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 5), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 5), state.viewer.source_vertical_scroll.line); _ = state.applyNavigation(allocator, .half_page_up, size); try std.testing.expectEqual(@as(usize, 5), state.viewer.source_cursor); @@ -6566,29 +6550,29 @@ test "repository source comfort page routes wheel page search boundaries and mou for ("needle") |byte| _ = state.applyNavigation(allocator, .{ .source_search_insert = byte }, size); _ = state.applyNavigation(allocator, .submit_source_search, size); try std.testing.expectEqual(@as(usize, 12), state.viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 11), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 11), state.viewer.source_vertical_scroll.line); _ = state.applyNavigation(allocator, .source_first, size); try std.testing.expectEqual(@as(usize, 0), state.viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 0), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 0), state.viewer.source_vertical_scroll.line); _ = state.applyNavigation(allocator, .source_last, size); try std.testing.expectEqual(@as(usize, 19), state.viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 17), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 17), state.viewer.source_vertical_scroll.line); state.viewer.source_cursor = 8; - state.viewer.source_vertical_scroll = 5; + state.viewer.source_vertical_scroll.line = 5; _ = state.applyNavigation(allocator, .{ .mouse_source_press = .{ .col = geometry.text_col, .row = geometry.body_first_row, } }, size); try std.testing.expectEqual(@as(usize, 5), state.viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 5), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 5), state.viewer.source_vertical_scroll.line); _ = state.applyNavigation(allocator, .{ .mouse_owner_drag = .{ .col = geometry.text_col, .row = geometry.body_first_row + geometry.visible_source_rows - 1, } }, size); try std.testing.expectEqual(@as(usize, 7), state.viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 5), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 5), state.viewer.source_vertical_scroll.line); } test "repository drag auto-scroll validates token and steps one projected row" { @@ -6604,7 +6588,7 @@ test "repository drag auto-scroll validates token and steps one projected row" { // tab-and-wide-glyph fixture row after adding the visual spacer. const size: chasen.Size = .{ .width = 60, .height = 7 }; const geometry = state.sourceGeometry(size, state.currentSource().?); - state.viewer.source_vertical_scroll = 5; + state.viewer.source_vertical_scroll.line = 5; _ = state.applyNavigation(allocator, .{ .mouse_source_press = .{ .col = geometry.text_col, .row = geometry.body_first_row, @@ -6624,7 +6608,7 @@ test "repository drag auto-scroll validates token and steps one projected row" { } }, size); defer update.deinit(allocator); try std.testing.expectEqual(drag_auto_scroll.StepOutcome.moved, update.auto_scroll.?); - try std.testing.expectEqual(@as(usize, 6), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 6), state.viewer.source_vertical_scroll.line); const live = state.selection_owner.activeSource().?; try std.testing.expect(live.token.eql(token_before)); try std.testing.expect(live.mode == .character); @@ -6665,21 +6649,21 @@ test "repository drag auto-scroll validates token and steps one projected row" { // auto-scroll steps operate on source rows only and Copy contains source // text only, never the status label. try installFirstLineCandidateForTest(&state, allocator); - state.viewer.source_vertical_scroll = 0; + state.viewer.source_vertical_scroll.line = 0; _ = state.applyNavigation(allocator, .{ .mouse_source_press = .{ .col = geometry.gutter_col, .row = viewport.first_row, } }, size); const line_token = state.selection_owner.activeSource().?.token; try std.testing.expect(state.selection_owner.activeSource().?.mode == .line); - state.viewer.source_vertical_scroll = 2; + state.viewer.source_vertical_scroll.line = 2; var status_row = state.applyNavigation(allocator, .{ .mouse_source_auto_scroll_step = .{ .direction = .up, .endpoint = .{ .col = geometry.text_col, .row = repository_source_geometry.source_search_or_rule_row }, } }, size); defer status_row.deinit(allocator); try std.testing.expectEqual(drag_auto_scroll.StepOutcome.moved, status_row.auto_scroll.?); - try std.testing.expectEqual(@as(usize, 1), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 1), state.viewer.source_vertical_scroll.line); for (0..3) |_| { var repeated = state.applyNavigation(allocator, .{ .mouse_source_auto_scroll_step = .{ @@ -6710,12 +6694,12 @@ test "repository drag auto-scroll validates token and steps one projected row" { else => return error.ExpectedSourceCopyCommand, } - state.viewer.source_vertical_scroll = 0; + state.viewer.source_vertical_scroll.line = 0; _ = state.applyNavigation(allocator, .{ .mouse_source_press = .{ .col = geometry.text_col, .row = viewport.first_row, } }, size); - state.viewer.source_vertical_scroll = state.currentSource().?.rowCount() -| geometry.visible_source_rows; + state.viewer.source_vertical_scroll.line = state.currentSource().?.rowCount() -| geometry.visible_source_rows; const edge_cursor = state.viewer.source_cursor; const edge_selection = state.selection_owner; var edge = state.applyNavigation(allocator, .{ .mouse_source_auto_scroll_step = .{ @@ -6738,6 +6722,61 @@ test "repository drag auto-scroll validates token and steps one projected row" { try std.testing.expect(!state.activeMouseOwner()); } +test "repository wrap pointer copy uses original bytes across continuations and replacement" { + const allocator = std.testing.allocator; + var state = try selectionStateForTest("main.zig\x00other.zig\x00", "abcd界\tneedle e\u{301}👩‍💻END\nnext\n"); + defer state.deinit(allocator); + state.viewer.tree_hidden = true; + const size: chasen.Size = .{ .width = 8, .height = 9 }; + try std.testing.expect(!state.viewer.line_wrap); + _ = state.applyNavigation(allocator, .toggle_line_wrap, size); + _ = state.applyNavigation(allocator, .{ .mouse_source_press = .{ .col = 4, .row = 4 } }, size); + try std.testing.expectEqual(@as(usize, 4), state.liveSourceSelection().?.anchor.leading_byte); + _ = state.applyNavigation(allocator, .{ .mouse_owner_drag = .{ .col = 1, .row = 5 } }, size); + try std.testing.expectEqual(@as(usize, 8), state.liveSourceSelection().?.focus.leading_byte); + var release = state.applyNavigation(allocator, .{ .mouse_owner_release = .{ .col = 5, .row = 6 } }, size); + defer release.deinit(allocator); + try std.testing.expectEqualStrings("界\tneedle", state.completed_selection.?.text); + var copy = state.applyNavigation(allocator, .{ .selection_action = .copy }, size); + defer copy.deinit(allocator); + try std.testing.expectEqualStrings("界\tneedle", copy.command.?.copy_source_selection.text); + + const narrow: chasen.Size = .{ .width = 5, .height = 5 }; + const top: repository_model.SourcePosition = .{ .intra = .{ + .byte = 4, + .fingerprint = state.currentSource().?.fingerprint, + } }; + state.viewer.source_vertical_scroll = top; + _ = state.applyNavigation(allocator, .{ .mouse_source_press = .{ .col = 4, .row = 3 } }, narrow); + var replacement = state.applyNavigation(allocator, .{ .mouse_owner_release = .{ .col = 1, .row = 3 } }, narrow); + defer replacement.deinit(allocator); + try std.testing.expectEqualStrings("界", state.completed_selection.?.text); + const selected_anchor = state.captureSelectionViewportAnchor().?; + state.restoreSelectionViewportAnchor(selected_anchor, .{ .width = 6, .height = 5 }); + try std.testing.expectEqualDeep(top, state.viewer.source_vertical_scroll); + var replacement_copy = state.applyNavigation(allocator, .{ .selection_action = .copy }, narrow); + defer replacement_copy.deinit(allocator); + try std.testing.expectEqualStrings("界", replacement_copy.command.?.copy_source_selection.text); + try std.testing.expectEqualDeep(top, state.viewer.source_vertical_scroll); + _ = state.applyNavigation(allocator, .{ .selection_action = .clear }, narrow); + try std.testing.expectEqualDeep(top, state.viewer.source_vertical_scroll); + _ = state.applyNavigation(allocator, .begin_keyboard_line_selection, narrow); + _ = state.applyNavigation(allocator, .mouse_source_wheel_down, narrow); + try std.testing.expectEqual(@as(usize, 0), state.viewer.source_cursor); + try std.testing.expectEqual(@as(usize, 0), state.liveSourceSelection().?.focus.line_index); + _ = state.applyNavigation(allocator, .toggle_line_wrap, narrow); + try std.testing.expect(state.activeKeyboardLineSelection()); + try std.testing.expect(!state.viewer.line_wrap); + try std.testing.expect(state.viewer.source_vertical_scroll.intra == null); + _ = state.applyNavigation(allocator, .toggle_line_wrap, narrow); + state.clearLiveSelection(); + _ = state.applyNavigation(allocator, .next_file, narrow); + try std.testing.expect(state.viewer.line_wrap); + try std.testing.expectEqualDeep(repository_model.SourcePosition{}, state.viewer.source_vertical_scroll); + state.repositoryChanged(allocator, 9, .{ .device = 10, .inode = 11 }); + try std.testing.expect(!state.viewer.line_wrap); +} + test "repository selection live gesture fixes mode and resumes after leaving the pane" { const allocator = std.testing.allocator; var state = try selectionStateForTest("main.zig\x00", "ABCDEFG\nHIJKLMN\nthird\nfourth\n"); @@ -6822,12 +6861,12 @@ test "repository selection hit testing applies scroll once and follows line numb try std.testing.expectEqual(@as(usize, "ABCDEFG".len), state.selection_owner.activeSource().?.anchor.trailing_byte); state.cancelMouseOwner(); - state.viewer.source_vertical_scroll = 1; + state.viewer.source_vertical_scroll.line = 1; _ = state.applyNavigation(allocator, .{ .mouse_source_press = .{ .col = geometry.text_col, .row = first_row } }, size); try std.testing.expectEqual(@as(usize, 1), state.selection_owner.activeSource().?.anchor.line_index); state.cancelMouseOwner(); - state.viewer.source_vertical_scroll = 0; + state.viewer.source_vertical_scroll.line = 0; state.viewer.source_horizontal_scroll = 0; _ = state.applyNavigation(allocator, .toggle_line_numbers, size); geometry = state.sourceGeometry(size, document); @@ -6865,7 +6904,12 @@ test "repository selection hit testing applies scroll once and follows line numb atomic.cancelMouseOwner(); } - const saturated = RepositoryPageState.pointAtTextCell(atomic_document, 0, std.math.maxInt(usize), 1).?; + const atomic_row = repository_source_layout.Layout.init(atomic_document, atomic.viewer, atomic_geometry).contentRowAt( + atomic_geometry, + .{}, + atomic_geometry.body_first_row, + ).?; + const saturated = RepositoryPageState.pointAtTextCell(atomic_row, std.math.maxInt(usize), 1).?; try std.testing.expectEqual(@as(usize, "a\t界e\u{301}👩‍💻z".len), saturated.leading_byte); try std.testing.expectEqual(saturated.leading_byte, saturated.trailing_byte); } @@ -7096,12 +7140,12 @@ test "repository fixed status row shares wide narrow render geometry and dispatc repository_input.keyToMsg(Msg, state.inputContext(.{}), .{ .codepoint = chasen.Key.escape }).?, ); - state.viewer.source_vertical_scroll = 1; + state.viewer.source_vertical_scroll.line = 1; var cleared = state.applyNavigation(allocator, .{ .selection_action = .clear }, narrow); defer cleared.deinit(allocator); try std.testing.expect(cleared.command == null); try std.testing.expect(state.completed_selection == null); - try std.testing.expectEqual(@as(usize, 1), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 1), state.viewer.source_vertical_scroll.line); } test "repository selection copy authority mismatch clears without command" { @@ -7127,13 +7171,13 @@ test "repository selection resize restores semantic presentation anchor" { var state = try selectionStateForTest("main.zig\x00", "one\ntwo\nthree\n"); defer state.deinit(allocator); try installFirstLineCandidateForTest(&state, allocator); - state.viewer.source_vertical_scroll = 1; + state.viewer.source_vertical_scroll.line = 1; const anchor = state.captureSelectionViewportAnchor() orelse return error.ExpectedSelectionViewportAnchor; state.restoreSelectionViewportAnchor(anchor, .{ .width = 40, .height = 4 }); - try std.testing.expectEqual(@as(usize, 1), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 1), state.viewer.source_vertical_scroll.line); state.restoreSelectionViewportAnchor(anchor, .{ .width = 120, .height = 32 }); - try std.testing.expectEqual(@as(usize, 0), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 0), state.viewer.source_vertical_scroll.line); try std.testing.expect(state.completed_selection != null); } @@ -7310,7 +7354,7 @@ test "repository selection reconciles accepted document and keeps other replacem test "repository document replacement restores semantic viewport across projection terminals" { const allocator = std.testing.allocator; - const original = "zero\none\ntwo\nthree\nfour\n"; + const original = "zero\none\nabcdefghijklmnopqrstuv\nthree\nfour\n"; var state = try selectionStateForTest("main.zig\x00", original); defer state.deinit(allocator); var drag = repository_selection.DragSelection.init( @@ -7324,7 +7368,12 @@ test "repository document replacement restores semantic viewport across projecti state.currentSource().?, drag, ); - state.viewer.source_vertical_scroll = 2; + state.viewer.line_wrap = true; + state.viewer.source_vertical_scroll = .{ .line = 2, .intra = .{ + .byte = 10, + .fingerprint = state.currentSource().?.fingerprint, + } }; + const anchor = state.viewer.source_vertical_scroll; state.document_generation = 8; state.pending_document_generation = 8; @@ -7340,11 +7389,13 @@ test "repository document replacement restores semantic viewport across projecti defer exact.deinit(allocator); try std.testing.expectEqual(ApplyOutcome.changed, state.applyDocumentFinished(allocator, &exact)); try std.testing.expect(state.completed_selection != null); - try std.testing.expectEqual(@as(usize, 2), state.viewer.source_vertical_scroll); + try std.testing.expect(state.viewer.line_wrap); + try std.testing.expectEqualDeep(anchor, state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 2), state.viewer.source_vertical_scroll.line); state.document_generation = 9; state.pending_document_generation = 9; - const changed_content = "ZERO\none\ntwo\nthree\nfour\n"; + const changed_content = "ZERO\none\nabcdefghijklmnopqrstuv\nthree\nfour\n"; const changed_bytes = try allocator.dupe(u8, changed_content); var changed: repository_tasks.DocumentFinished = .{ .identity = .{ .origin = .repository, .repo_epoch = state.repo_epoch, .activation_id = state.activation_id }, @@ -7357,7 +7408,9 @@ test "repository document replacement restores semantic viewport across projecti defer changed.deinit(allocator); try std.testing.expectEqual(ApplyOutcome.changed, state.applyDocumentFinished(allocator, &changed)); try std.testing.expect(state.completed_selection == null); - try std.testing.expectEqual(@as(usize, 2), state.viewer.source_vertical_scroll); + try std.testing.expect(state.viewer.line_wrap); + try std.testing.expect(state.viewer.source_vertical_scroll.intra == null); + try std.testing.expectEqual(@as(usize, 2), state.viewer.source_vertical_scroll.line); } test "repository selection survives presentation changes while source search starts with a clear" { @@ -7463,7 +7516,7 @@ test "repository source completion rejects stale identity and frees undelivered defer state.deinit(allocator); state.selected_path = state.bundle.?.tree.firstFilePath(); state.viewer.source_cursor = 20; - state.viewer.source_vertical_scroll = 10; + state.viewer.source_vertical_scroll.line = 10; state.viewer.source_horizontal_scroll = 30; try state.source_search.query.insertSlice("retained-on-stale"); state.source_search.mode = true; @@ -7513,7 +7566,7 @@ test "repository source completion rejects stale identity and frees undelivered state.displayed_document.?.metadata.?.modified_at.nanoseconds, ); try std.testing.expectEqual(@as(usize, 0), state.viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 0), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 0), state.viewer.source_vertical_scroll.line); try std.testing.expectEqual(@as(usize, 0), state.viewer.source_horizontal_scroll); try std.testing.expectEqual(@as(usize, 0), state.source_search.query.len); try std.testing.expect(!state.source_search.mode); @@ -7752,7 +7805,7 @@ test "repository incoming viewport scroll preserves same-target source viewport" state.freshness = .fresh; state.viewer.focus = .source; state.viewer.source_cursor = 6; - state.viewer.source_vertical_scroll = 0; + state.viewer.source_vertical_scroll.line = 0; state.viewer.source_horizontal_scroll = 9; const identity = state.root_identity.?; @@ -7771,7 +7824,7 @@ test "repository incoming viewport scroll preserves same-target source viewport" try std.testing.expect(state.incoming == .none); try std.testing.expectEqual(repository_model.Focus.source, state.viewer.focus); try std.testing.expectEqual(@as(usize, 6), state.viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 0), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 0), state.viewer.source_vertical_scroll.line); try std.testing.expectEqual(@as(usize, 9), state.viewer.source_horizontal_scroll); } @@ -8218,7 +8271,7 @@ test "repository body file navigation follows filtered tree order and resets onl state.viewer.focus = .source; state.viewer.tree_cursor = 0; // The root cursor does not replace the body target. state.viewer.source_cursor = 12; - state.viewer.source_vertical_scroll = 10; + state.viewer.source_vertical_scroll.line = 10; state.viewer.source_horizontal_scroll = 20; try state.source_search.query.insertSlice("needle"); state.source_search.match = state.currentSource().?.findNext("needle", null); @@ -8245,7 +8298,7 @@ test "repository body file navigation follows filtered tree order and resets onl try std.testing.expect(state.completed_selection == null); try std.testing.expectEqual(@as(usize, 0), state.source_search.query.len); try std.testing.expect(state.source_search.match == null); - try std.testing.expectEqual(@as(usize, 0), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 0), state.viewer.source_vertical_scroll.line); try std.testing.expectEqual(@as(usize, 0), state.viewer.source_horizontal_scroll); try std.testing.expectEqual(repository_model.Focus.source, state.inputContext(.{}).focus); try std.testing.expectEqual(repository_tree.Visibility.all, state.file_visibility); @@ -8620,13 +8673,13 @@ test "repository transition resolves an already accepted source without a task" try std.testing.expectEqual(source_address, @intFromPtr(state.currentSource().?)); try std.testing.expectEqual(repository_model.Focus.source, state.viewer.focus); try std.testing.expectEqual(case.cursor, state.viewer.source_cursor); - try std.testing.expectEqual(case.cursor, state.viewer.source_vertical_scroll); + try std.testing.expectEqual(case.cursor, state.viewer.source_vertical_scroll.line); try std.testing.expect(!state.needs_document_revalidation); } state.viewer.focus = .tree; state.viewer.source_cursor = 1; - state.viewer.source_vertical_scroll = 1; + state.viewer.source_vertical_scroll.line = 1; var path_only = try page_link.RepositoryIncoming.initOwned( allocator, state.repo_epoch, @@ -8638,7 +8691,7 @@ test "repository transition resolves an already accepted source without a task" try std.testing.expect(state.incoming == .none); try std.testing.expectEqual(repository_model.Focus.tree, state.viewer.focus); try std.testing.expectEqual(@as(usize, 1), state.viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 1), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 1), state.viewer.source_vertical_scroll.line); } fn expectReactivatedIncomingDocumentForTest( @@ -8655,7 +8708,7 @@ fn expectReactivatedIncomingDocumentForTest( state.freshness = .fresh; state.viewer.focus = .tree; state.viewer.source_cursor = 1; - state.viewer.source_vertical_scroll = 1; + state.viewer.source_vertical_scroll.line = 1; state.deactivate(); var incoming = try page_link.RepositoryIncoming.initOwned( @@ -8672,7 +8725,7 @@ fn expectReactivatedIncomingDocumentForTest( try std.testing.expect(!state.needs_document_revalidation); try std.testing.expectEqual(repository_model.Focus.tree, state.viewer.focus); try std.testing.expectEqual(@as(usize, 1), state.viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 1), state.viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 1), state.viewer.source_vertical_scroll.line); var manifest_request = try state.prepareRequest(allocator, root.path, &root.capability); defer manifest_request.deinit(allocator); @@ -8710,7 +8763,7 @@ fn expectReactivatedIncomingDocumentForTest( if (expected_cursor) |cursor| { try std.testing.expect(state.incoming == .none); try std.testing.expectEqual(cursor, state.viewer.source_cursor); - try std.testing.expectEqual(cursor, state.viewer.source_vertical_scroll); + try std.testing.expectEqual(cursor, state.viewer.source_vertical_scroll.line); try std.testing.expectEqual(repository_model.Focus.source, state.viewer.focus); try std.testing.expectEqualStrings(replacement_source.?, state.currentSource().?.bytes); } else { @@ -8781,7 +8834,7 @@ fn expectIncomingDocumentLineForTest( try std.testing.expectEqual(ApplyOutcome.changed, state.applyDocumentFinished(allocator, &finished)); try std.testing.expect(state.incoming == .none); try std.testing.expectEqual(expected_cursor, state.viewer.source_cursor); - try std.testing.expectEqual(expected_cursor, state.viewer.source_vertical_scroll); + try std.testing.expectEqual(expected_cursor, state.viewer.source_vertical_scroll.line); try std.testing.expectEqual(repository_model.Focus.source, state.viewer.focus); try std.testing.expectEqual(!inactive_completion, state.active); } diff --git a/src/app/pages/repository/input.zig b/src/app/pages/repository/input.zig index 2f4702c..9b73f9c 100644 --- a/src/app/pages/repository/input.zig +++ b/src/app/pages/repository/input.zig @@ -114,6 +114,7 @@ fn publicActionToMsg(comptime Msg: type, context: Context, action: keymap.Public .file_search => voidMsg(Msg, "enter_file_search"), .changed_file_filter => voidMsg(Msg, "toggle_changed_filter"), .toggle_line_numbers => voidMsg(Msg, "toggle_line_numbers"), + .toggle_line_wrap => voidMsg(Msg, "toggle_line_wrap"), .page_up => voidMsg(Msg, "page_up"), .page_down => voidMsg(Msg, "page_down"), .toggle_sidebar => voidMsg(Msg, "toggle_tree_visibility"), @@ -196,6 +197,7 @@ const TestMsg = union(enum) { enter_file_search, toggle_changed_filter, toggle_line_numbers, + toggle_line_wrap, clear_source_search, next_source_match, previous_source_match, diff --git a/src/app/pages/repository/model.zig b/src/app/pages/repository/model.zig index b5d684c..17ef048 100644 --- a/src/app/pages/repository/model.zig +++ b/src/app/pages/repository/model.zig @@ -8,6 +8,25 @@ pub const max_file_search_matches: usize = 512; pub const Focus = enum { tree, source }; +/// The source viewport's only retained position. Fragment rows are derived. +pub const SourcePosition = struct { + line: usize = 0, + intra: ?struct { + byte: usize, + tab_cells: usize = 0, + fingerprint: @import("../../../content_fingerprint.zig").Fingerprint, + } = null, + + pub fn admitted(self: SourcePosition, document: *const source.Document) SourcePosition { + var result = self; + result.line = @min(result.line, document.rowCount() - 1); + if (result.intra) |intra| { + if (result.line != self.line or !intra.fingerprint.eql(document.fingerprint)) result.intra = null; + } + return result; + } +}; + pub const ViewerState = struct { focus: Focus = .tree, /// Optional user preference. Effective width is always clamped against the @@ -25,13 +44,14 @@ pub const ViewerState = struct { tree_vertical_scroll: usize = 0, tree_horizontal_scroll: usize = 0, source_cursor: usize = 0, - source_vertical_scroll: usize = 0, + source_vertical_scroll: SourcePosition = .{}, source_horizontal_scroll: usize = 0, line_numbers: bool = true, + line_wrap: bool = false, pub fn resetSource(self: *ViewerState) void { self.source_cursor = 0; - self.source_vertical_scroll = 0; + self.source_vertical_scroll = .{}; self.source_horizontal_scroll = 0; } }; diff --git a/src/app/pages/repository/navigation.zig b/src/app/pages/repository/navigation.zig index 716f4ea..8cf8361 100644 --- a/src/app/pages/repository/navigation.zig +++ b/src/app/pages/repository/navigation.zig @@ -3,6 +3,7 @@ const std = @import("std"); const cursor_viewport = @import("../../cursor_viewport.zig"); const model = @import("model.zig"); +const source_layout = @import("source_layout.zig"); const source_geometry = @import("source_geometry.zig"); const source = @import("../../../repository/source.zig"); const selected_document = @import("../../../repository/document.zig"); @@ -27,10 +28,16 @@ fn moveSourceCursor(viewer: *model.ViewerState, document: *const source.Document pub fn moveSource(viewer: *model.ViewerState, document: *const source.Document, delta: isize, geometry: source_geometry.SourceGeometry) void { moveSourceCursor(viewer, document, delta); + if (viewer.line_wrap) { + viewer.source_cursor = @min(viewer.source_cursor, document.rowCount() - 1); + const layout = source_layout.Layout.init(document, viewer.*, geometry); + viewer.source_vertical_scroll = layout.reveal(viewer.source_vertical_scroll, .{ .line = viewer.source_cursor }, geometry.visible_source_rows, false); + return; + } clampSource(viewer, document, geometry); - viewer.source_vertical_scroll = cursor_viewport.placeCursorInComfortBand( + viewer.source_vertical_scroll.line = cursor_viewport.placeCursorInComfortBand( sourceBounds(document, geometry), - viewer.source_vertical_scroll, + viewer.source_vertical_scroll.line, viewer.source_cursor, ); } @@ -51,12 +58,18 @@ fn pageSourceByRows( step: usize, geometry: source_geometry.SourceGeometry, ) void { + if (viewer.line_wrap) { + const layout = source_layout.Layout.init(document, viewer.*, geometry); + viewer.source_vertical_scroll = layout.scroll(viewer.source_vertical_scroll, direction, step, geometry.visible_source_rows); + retainVisibleCursor(viewer, layout, geometry.visible_source_rows); + return; + } const target = if (direction < 0) viewer.source_cursor -| step else viewer.source_cursor +| step; viewer.source_cursor = @min(target, document.rowCount() - 1); clampSource(viewer, document, geometry); - viewer.source_vertical_scroll = cursor_viewport.centerCursor( + viewer.source_vertical_scroll.line = cursor_viewport.centerCursor( sourceBounds(document, geometry), - viewer.source_vertical_scroll, + viewer.source_vertical_scroll.line, viewer.source_cursor, ); } @@ -74,8 +87,19 @@ pub fn autoScrollSource(viewer: *model.ViewerState, document: *const source.Docu } fn scrollSource(viewer: *model.ViewerState, document: *const source.Document, direction: isize, geometry: source_geometry.SourceGeometry, origin: enum { pointer, drag }) void { + if (viewer.line_wrap) { + const layout = source_layout.Layout.init(document, viewer.*, geometry); + const old = layout.clamp(viewer.source_vertical_scroll, geometry.visible_source_rows); + viewer.source_vertical_scroll = layout.scroll(old, direction, 1, geometry.visible_source_rows); + if (std.meta.eql(old, viewer.source_vertical_scroll)) { + if (origin == .pointer and direction != 0 and geometry.visible_source_rows != 0 and + document.contentLineCount() != 0 and viewer.source_cursor < document.rowCount()) + moveSourceCursor(viewer, document, if (direction < 0) -1 else 1); + } else retainVisibleCursor(viewer, layout, geometry.visible_source_rows); + return; + } const bounds = sourceBounds(document, geometry); - const old_scroll = bounds.clampScroll(viewer.source_vertical_scroll); + const old_scroll = bounds.clampScroll(viewer.source_vertical_scroll.line); const requested_scroll = if (direction < 0) old_scroll -| 1 else if (direction > 0) @@ -83,7 +107,7 @@ fn scrollSource(viewer: *model.ViewerState, document: *const source.Document, di else old_scroll; const new_scroll = bounds.clampScroll(requested_scroll); - viewer.source_vertical_scroll = new_scroll; + viewer.source_vertical_scroll.line = new_scroll; if (old_scroll == new_scroll) { if (origin == .drag or bounds.visible_rows == 0 or direction == 0 or document.contentLineCount() == 0) return; if (viewer.source_cursor >= document.rowCount()) return; @@ -108,24 +132,38 @@ pub fn scrollSourceViewport( direction: isize, geometry: source_geometry.SourceGeometry, ) void { + if (viewer.line_wrap) { + const layout = source_layout.Layout.init(document, viewer.*, geometry); + viewer.source_vertical_scroll = layout.scroll(viewer.source_vertical_scroll, direction, 1, geometry.visible_source_rows); + return; + } const bounds = sourceBounds(document, geometry); - const current = bounds.clampScroll(viewer.source_vertical_scroll); + const current = bounds.clampScroll(viewer.source_vertical_scroll.line); const requested = if (direction < 0) current -| 1 else if (direction > 0) current +| 1 else current; - viewer.source_vertical_scroll = bounds.clampScroll(requested); + viewer.source_vertical_scroll.line = bounds.clampScroll(requested); } pub fn firstSource(viewer: *model.ViewerState, document: *const source.Document, geometry: source_geometry.SourceGeometry) void { viewer.source_cursor = 0; + if (viewer.line_wrap) { + viewer.source_vertical_scroll = .{}; + return; + } clampSource(viewer, document, geometry); } pub fn lastSource(viewer: *model.ViewerState, document: *const source.Document, geometry: source_geometry.SourceGeometry) void { viewer.source_cursor = document.rowCount() - 1; + if (viewer.line_wrap) { + const layout = source_layout.Layout.init(document, viewer.*, geometry); + viewer.source_vertical_scroll = layout.reveal(viewer.source_vertical_scroll, .{ .line = viewer.source_cursor }, geometry.visible_source_rows, false); + return; + } clampSource(viewer, document, geometry); } @@ -141,15 +179,21 @@ pub fn gotoSourceLine( std.debug.assert(line_index < document.contentLineCount()); viewer.focus = .source; viewer.source_cursor = line_index; + if (viewer.line_wrap) { + const layout = source_layout.Layout.init(document, viewer.*, geometry); + viewer.source_vertical_scroll = layout.reveal(viewer.source_vertical_scroll, .{ .line = line_index }, geometry.visible_source_rows, true); + return; + } clampSource(viewer, document, geometry); - viewer.source_vertical_scroll = cursor_viewport.centerCursor( + viewer.source_vertical_scroll.line = cursor_viewport.centerCursor( sourceBounds(document, geometry), - viewer.source_vertical_scroll, + viewer.source_vertical_scroll.line, viewer.source_cursor, ); } pub fn scrollSourceHorizontal(viewer: *model.ViewerState, document: *const source.Document, delta: isize, geometry: source_geometry.SourceGeometry) void { + if (viewer.line_wrap) return; if (delta < 0) viewer.source_horizontal_scroll -|= @intCast(-delta) else @@ -163,11 +207,23 @@ pub fn clampSource( document: *const source.Document, geometry: source_geometry.SourceGeometry, ) void { + if (viewer.line_wrap) { + const layout = source_layout.Layout.init(document, viewer.*, geometry); + viewer.source_cursor = @min(viewer.source_cursor, document.rowCount() - 1); + viewer.source_vertical_scroll = layout.clamp(viewer.source_vertical_scroll, geometry.visible_source_rows); + if (geometry.visible_source_rows > 0) { + const visible = layout.visibleLines(viewer.source_vertical_scroll, geometry.visible_source_rows); + if (viewer.source_cursor < visible.first or viewer.source_cursor > visible.last) + viewer.source_vertical_scroll = layout.reveal(viewer.source_vertical_scroll, .{ .line = viewer.source_cursor }, geometry.visible_source_rows, false); + } + return; + } + viewer.source_vertical_scroll.intra = null; const bounds = sourceBounds(document, geometry); viewer.source_cursor = @min(viewer.source_cursor, document.rowCount() - 1); - viewer.source_vertical_scroll = cursor_viewport.keepCursorVisible( + viewer.source_vertical_scroll.line = cursor_viewport.keepCursorVisible( bounds, - viewer.source_vertical_scroll, + viewer.source_vertical_scroll.line, viewer.source_cursor, ); const maximum = document.maxDisplayWidth() -| @as(usize, geometry.text_width); @@ -182,19 +238,33 @@ pub fn revealMatch( ) void { viewer.focus = .source; viewer.source_cursor = match.line; + if (viewer.line_wrap) { + const layout = source_layout.Layout.init(document, viewer.*, geometry); + viewer.source_vertical_scroll = layout.reveal(viewer.source_vertical_scroll, .{ .line = match.line, .intra = .{ + .byte = match.start, + .fingerprint = document.fingerprint, + } }, geometry.visible_source_rows, false); + return; + } const match_col = document.displayColumnForByte(match.line, match.start) orelse 0; if (match_col < viewer.source_horizontal_scroll) viewer.source_horizontal_scroll = match_col; if (geometry.text_width > 0 and match_col >= viewer.source_horizontal_scroll + geometry.text_width) { viewer.source_horizontal_scroll = match_col - geometry.text_width + 1; } clampSource(viewer, document, geometry); - viewer.source_vertical_scroll = cursor_viewport.placeCursorInComfortBand( + viewer.source_vertical_scroll.line = cursor_viewport.placeCursorInComfortBand( sourceBounds(document, geometry), - viewer.source_vertical_scroll, + viewer.source_vertical_scroll.line, viewer.source_cursor, ); } +fn retainVisibleCursor(viewer: *model.ViewerState, layout: source_layout.Layout, visible_rows: usize) void { + if (visible_rows == 0) return; + const visible = layout.visibleLines(viewer.source_vertical_scroll, visible_rows); + viewer.source_cursor = std.math.clamp(viewer.source_cursor, visible.first, visible.last); +} + fn sourceDocumentWithRowsForTest(allocator: std.mem.Allocator, rows: usize) !source.Document { const bytes = try allocator.alloc(u8, rows); errdefer allocator.free(bytes); @@ -202,6 +272,47 @@ fn sourceDocumentWithRowsForTest(allocator: std.mem.Allocator, rows: usize) !sou return source.Document.initOwned(allocator, bytes, .init(bytes)); } +test "repository wrap navigation reaches tall line tail and reveals same-line occurrences" { + const allocator = std.testing.allocator; + const line = "x" ** 40 ++ "needle" ++ "y" ** 40 ++ "needleEND"; + const bytes = try allocator.dupe(u8, line ++ "\nnext\n"); + var document = try source.Document.initOwnedOrFree(allocator, bytes, .init(bytes)); + defer document.deinit(allocator); + const geometry = source_geometry.SourceGeometry.init(.{ .width = 12, .height = 6 }, &document, false); + var viewer: model.ViewerState = .{ .line_wrap = true, .focus = .source }; + pageSource(&viewer, &document, 1, geometry); + try std.testing.expectEqual(@as(usize, 30), viewer.source_vertical_scroll.intra.?.byte); + try std.testing.expectEqual(@as(usize, 0), viewer.source_cursor); + wheelSource(&viewer, &document, 1, geometry); + try std.testing.expectEqual(@as(usize, 40), viewer.source_vertical_scroll.intra.?.byte); + const first_match = document.findNext("needle", null).?; + const second_match = document.findNext("needle", first_match).?; + try std.testing.expectEqual(@as(usize, 86), second_match.start); + revealMatch(&viewer, &document, second_match, geometry); + const layout = source_layout.Layout.init(&document, viewer, geometry); + const match_row = layout.contentRowAt(geometry, viewer.source_vertical_scroll, geometry.body_first_row + 1).?; + try std.testing.expect(match_row.fragment.?.bytes.start <= second_match.start); + try std.testing.expect(match_row.fragment.?.bytes.end > second_match.start); + revealMatch(&viewer, &document, document.findPrevious("needle", second_match).?, geometry); + try std.testing.expectEqual(@as(usize, 30), viewer.source_vertical_scroll.intra.?.byte); + for (0..20) |_| wheelSource(&viewer, &document, 1, geometry); + try std.testing.expectEqual(@as(usize, 80), viewer.source_vertical_scroll.intra.?.byte); + const bottom = viewer.source_vertical_scroll; + autoScrollSource(&viewer, &document, 1, geometry); + try std.testing.expectEqualDeep(bottom, viewer.source_vertical_scroll); + // Wheel reaches the next source line at the edge; explicit k returns to + // the first fragment of the preceding logical line. + try std.testing.expectEqual(@as(usize, 1), viewer.source_cursor); + moveSource(&viewer, &document, -1, geometry); + try std.testing.expectEqual(@as(usize, 0), viewer.source_cursor); + try std.testing.expect(viewer.source_vertical_scroll.intra == null); + moveSource(&viewer, &document, 1, geometry); + try std.testing.expectEqual(@as(usize, 1), viewer.source_cursor); + try std.testing.expectEqualDeep(bottom, viewer.source_vertical_scroll); + scrollSourceHorizontal(&viewer, &document, 8, geometry); + try std.testing.expectEqual(@as(usize, 0), viewer.source_horizontal_scroll); +} + test "repository source comfort keeps single row navigation in band and permits content edges" { const allocator = std.testing.allocator; var document = try sourceDocumentWithRowsForTest(allocator, 100); @@ -212,35 +323,35 @@ test "repository source comfort keeps single row navigation in band and permits var viewer: model.ViewerState = .{ .focus = .source, .source_cursor = 23, - .source_vertical_scroll = 20, + .source_vertical_scroll = .{ .line = 20 }, }; moveSource(&viewer, &document, 1, geometry); try std.testing.expectEqual(@as(usize, 24), viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll.line); moveSource(&viewer, &document, 1, geometry); try std.testing.expectEqual(@as(usize, 25), viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll.line); moveSource(&viewer, &document, 1, geometry); try std.testing.expectEqual(@as(usize, 26), viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 21), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 21), viewer.source_vertical_scroll.line); viewer.source_cursor = 23; - viewer.source_vertical_scroll = 20; + viewer.source_vertical_scroll.line = 20; moveSource(&viewer, &document, -1, geometry); try std.testing.expectEqual(@as(usize, 22), viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll.line); viewer.source_cursor = 20; - viewer.source_vertical_scroll = 20; + viewer.source_vertical_scroll.line = 20; halfPageSource(&viewer, &document, 1, geometry); try std.testing.expectEqual(@as(usize, 24), viewer.source_cursor); firstSource(&viewer, &document, geometry); try std.testing.expectEqual(@as(usize, 0), viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 0), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 0), viewer.source_vertical_scroll.line); lastSource(&viewer, &document, geometry); try std.testing.expectEqual(@as(usize, 99), viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 92), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 92), viewer.source_vertical_scroll.line); } test "repository source wheel preserves visible screen rows and clamps offscreen cursors" { @@ -251,43 +362,43 @@ test "repository source wheel preserves visible screen rows and clamps offscreen var viewer: model.ViewerState = .{ .focus = .source, .source_cursor = 20, - .source_vertical_scroll = 20, + .source_vertical_scroll = .{ .line = 20 }, .source_horizontal_scroll = 7, }; for (0..geometry.visible_source_rows) |screen_row| { viewer.source_cursor = 20 + screen_row; - viewer.source_vertical_scroll = 20; + viewer.source_vertical_scroll.line = 20; wheelSource(&viewer, &document, 1, geometry); - try std.testing.expectEqual(@as(usize, 21), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 21), viewer.source_vertical_scroll.line); try std.testing.expectEqual(21 + screen_row, viewer.source_cursor); wheelSource(&viewer, &document, -1, geometry); - try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll.line); try std.testing.expectEqual(20 + screen_row, viewer.source_cursor); try std.testing.expectEqual(@as(usize, 7), viewer.source_horizontal_scroll); } viewer.source_cursor = 0; wheelSource(&viewer, &document, 1, geometry); - try std.testing.expectEqual(@as(usize, 21), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 21), viewer.source_vertical_scroll.line); try std.testing.expectEqual(@as(usize, 21), viewer.source_cursor); viewer.source_cursor = 29; wheelSource(&viewer, &document, -1, geometry); - try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll.line); try std.testing.expectEqual(@as(usize, 27), viewer.source_cursor); viewer.source_cursor = 4; - viewer.source_vertical_scroll = 0; + viewer.source_vertical_scroll.line = 0; wheelSource(&viewer, &document, -1, geometry); - try std.testing.expectEqual(@as(usize, 0), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 0), viewer.source_vertical_scroll.line); try std.testing.expectEqual(@as(usize, 3), viewer.source_cursor); viewer.source_cursor = 95; - viewer.source_vertical_scroll = 92; + viewer.source_vertical_scroll.line = 92; wheelSource(&viewer, &document, 1, geometry); - try std.testing.expectEqual(@as(usize, 92), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 92), viewer.source_vertical_scroll.line); try std.testing.expectEqual(@as(usize, 96), viewer.source_cursor); } @@ -301,16 +412,16 @@ test "repository wheel reaches both content edges for long short and exact-fit d for ([_]isize{ -1, 1 }) |direction| { var viewer: model.ViewerState = .{ .source_cursor = rows / 2, - .source_vertical_scroll = if (direction < 0) 0 else max_scroll, + .source_vertical_scroll = .{ .line = if (direction < 0) 0 else max_scroll }, .source_horizontal_scroll = 7, }; - const edge_scroll = viewer.source_vertical_scroll; + const edge_scroll = viewer.source_vertical_scroll.line; var expected = viewer.source_cursor; for (0..rows + 2) |_| { expected = if (direction < 0) expected -| 1 else @min(expected + 1, rows - 1); wheelSource(&viewer, &document, direction, geometry); try std.testing.expectEqual(expected, viewer.source_cursor); - try std.testing.expectEqual(edge_scroll, viewer.source_vertical_scroll); + try std.testing.expectEqual(edge_scroll, viewer.source_vertical_scroll.line); try std.testing.expectEqual(@as(usize, 7), viewer.source_horizontal_scroll); } } @@ -327,13 +438,13 @@ test "repository wheel edge leaves drag viewport-only zero-height and zero inten autoScrollSource(&viewer, &document, direction, geometry); scrollSourceViewport(&viewer, &document, direction, geometry); try std.testing.expectEqual(@as(usize, 2), viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 0), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 0), viewer.source_vertical_scroll.line); } wheelSource(&viewer, &document, 0, geometry); try std.testing.expectEqual(@as(usize, 2), viewer.source_cursor); const zero = source_geometry.SourceGeometry.init(.{ .width = 80, .height = 2 }, &document, true); for ([_]isize{ -1, 1 }) |direction| { - viewer.source_vertical_scroll = if (direction < 0) 0 else sourceBounds(&document, zero).maxScroll(); + viewer.source_vertical_scroll.line = if (direction < 0) 0 else sourceBounds(&document, zero).maxScroll(); wheelSource(&viewer, &document, direction, zero); try std.testing.expectEqual(@as(usize, 2), viewer.source_cursor); } @@ -347,25 +458,25 @@ test "repository source comfort centers pages and places explicit search in band var viewer: model.ViewerState = .{ .focus = .source, .source_cursor = 24, - .source_vertical_scroll = 20, + .source_vertical_scroll = .{ .line = 20 }, }; pageSource(&viewer, &document, 1, geometry); try std.testing.expectEqual(@as(usize, 33), viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 29), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 29), viewer.source_vertical_scroll.line); pageSource(&viewer, &document, -1, geometry); try std.testing.expectEqual(@as(usize, 24), viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll.line); halfPageSource(&viewer, &document, 1, geometry); try std.testing.expectEqual(@as(usize, 28), viewer.source_cursor); halfPageSource(&viewer, &document, -1, geometry); try std.testing.expectEqual(@as(usize, 24), viewer.source_cursor); - viewer.source_vertical_scroll = 0; + viewer.source_vertical_scroll.line = 0; revealMatch(&viewer, &document, .{ .line = 50, .start = 0, .end = 0 }, geometry); try std.testing.expectEqual(@as(usize, 50), viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 45), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 45), viewer.source_vertical_scroll.line); } test "repository source line jump centers real rows and preserves horizontal scroll" { @@ -380,7 +491,7 @@ test "repository source line jump centers real rows and preserves horizontal scr var viewer: model.ViewerState = .{ .focus = .source, .source_cursor = 1, - .source_vertical_scroll = 0, + .source_vertical_scroll = .{ .line = 0 }, .source_horizontal_scroll = 5, }; @@ -390,7 +501,7 @@ test "repository source line jump centers real rows and preserves horizontal scr try std.testing.expectEqual(@as(usize, 5), viewer.source_horizontal_scroll); try std.testing.expectEqual( cursor_viewport.centerCursor(sourceBounds(&document, geometry), 0, 14), - viewer.source_vertical_scroll, + viewer.source_vertical_scroll.line, ); } @@ -403,50 +514,50 @@ test "repository source comfort keeps reconciliation minimal and handles tiny vi var viewer: model.ViewerState = .{ .focus = .source, .source_cursor = 24, - .source_vertical_scroll = 20, + .source_vertical_scroll = .{ .line = 20 }, }; clampSource(&viewer, &document, regular); - try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll.line); const two_rows = source_geometry.SourceGeometry.init(.{ .width = 80, .height = 5 }, &document, true); clampSource(&viewer, &document, two_rows); - try std.testing.expectEqual(@as(usize, 23), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 23), viewer.source_vertical_scroll.line); const zero_rows = source_geometry.SourceGeometry.init(.{ .width = 80, .height = 2 }, &document, true); viewer.source_cursor = 5; - viewer.source_vertical_scroll = 20; + viewer.source_vertical_scroll.line = 20; moveSource(&viewer, &document, 1, zero_rows); try std.testing.expectEqual(@as(usize, 6), viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll.line); wheelSource(&viewer, &document, 1, zero_rows); try std.testing.expectEqual(@as(usize, 6), viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 21), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 21), viewer.source_vertical_scroll.line); halfPageSource(&viewer, &document, 1, zero_rows); try std.testing.expectEqual(@as(usize, 7), viewer.source_cursor); const spacer_only = source_geometry.SourceGeometry.init(.{ .width = 80, .height = 3 }, &document, true); viewer.source_cursor = 20; - viewer.source_vertical_scroll = 20; + viewer.source_vertical_scroll.line = 20; moveSource(&viewer, &document, 1, spacer_only); - try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 20), viewer.source_vertical_scroll.line); const one_row = source_geometry.SourceGeometry.init(.{ .width = 80, .height = 4 }, &document, true); viewer.source_cursor = 21; - viewer.source_vertical_scroll = 20; + viewer.source_vertical_scroll.line = 20; moveSource(&viewer, &document, 1, one_row); - try std.testing.expectEqual(@as(usize, 22), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 22), viewer.source_vertical_scroll.line); viewer.source_cursor = 21; - viewer.source_vertical_scroll = 20; + viewer.source_vertical_scroll.line = 20; moveSource(&viewer, &document, 1, two_rows); - try std.testing.expectEqual(@as(usize, 21), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 21), viewer.source_vertical_scroll.line); var empty = try sourceDocumentWithRowsForTest(allocator, 0); defer empty.deinit(allocator); var empty_viewer: model.ViewerState = .{ .focus = .source, .source_cursor = 99, - .source_vertical_scroll = 99, + .source_vertical_scroll = .{ .line = 99 }, }; clampSource(&empty_viewer, &empty, two_rows); wheelSource(&empty_viewer, &empty, 1, two_rows); @@ -455,7 +566,7 @@ test "repository source comfort keeps reconciliation minimal and handles tiny vi firstSource(&empty_viewer, &empty, two_rows); lastSource(&empty_viewer, &empty, two_rows); try std.testing.expectEqual(@as(usize, 0), empty_viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 0), empty_viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 0), empty_viewer.source_vertical_scroll.line); } /// File search follows the active tree projection even though it scans raw @@ -493,7 +604,7 @@ test "repository selection navigation clamps cursor scroll and horizontal cells" var viewer: model.ViewerState = .{ .focus = .source }; moveSource(&viewer, &document, 20, geometry); try std.testing.expectEqual(@as(usize, 2), viewer.source_cursor); - try std.testing.expectEqual(@as(usize, 2), viewer.source_vertical_scroll); + try std.testing.expectEqual(@as(usize, 2), viewer.source_vertical_scroll.line); scrollSourceHorizontal(&viewer, &document, 8, geometry); // Two gutter columns, a line number, and a separator leave three text cells. try std.testing.expectEqual(@as(usize, 7), viewer.source_horizontal_scroll); diff --git a/src/app/pages/repository/source_geometry.zig b/src/app/pages/repository/source_geometry.zig index a1d9327..f8babc5 100644 --- a/src/app/pages/repository/source_geometry.zig +++ b/src/app/pages/repository/source_geometry.zig @@ -72,19 +72,6 @@ pub const SourceGeometry = struct { if (col >= self.text_col) return .text; return null; } - - /// Returns only real content rows. `Document.rowCount()` deliberately has - /// one synthetic row for an empty viewer, which is not selectable text. - pub fn contentLineAt( - self: SourceGeometry, - row: u16, - vertical_scroll: usize, - document: *const source.Document, - ) ?usize { - if (row < self.body_first_row or row >= self.height) return null; - const line_index = vertical_scroll + @as(usize, row - self.body_first_row); - return if (line_index < document.contentLineCount()) line_index else null; - } }; fn decimalDigits(value: usize) usize { @@ -112,12 +99,6 @@ test "repository selection geometry shares narrow line number and body boundarie try std.testing.expectEqual(Region.line_number, numbered.regionAt(3).?); try std.testing.expectEqual(Region.separator, numbered.regionAt(4).?); try std.testing.expectEqual(Region.text, numbered.regionAt(5).?); - try std.testing.expectEqual(@as(?usize, null), numbered.contentLineAt(source_path_row, 0, &document)); - try std.testing.expectEqual(@as(?usize, null), numbered.contentLineAt(source_search_or_rule_row, 0, &document)); - try std.testing.expectEqual(@as(?usize, null), numbered.contentLineAt(source_spacer_row, 0, &document)); - try std.testing.expectEqual(@as(?usize, 0), numbered.contentLineAt(numbered.body_first_row, 0, &document)); - try std.testing.expectEqual(@as(?usize, 1), numbered.contentLineAt(numbered.body_first_row + 1, 0, &document)); - try std.testing.expectEqual(@as(?usize, null), numbered.contentLineAt(numbered.height, 0, &document)); const unnumbered = SourceGeometry.init(.{ .width = 10, .height = 5 }, &document, false); try std.testing.expectEqual(@as(?u16, null), unnumbered.separator_col); @@ -134,14 +115,3 @@ test "repository selection geometry shares narrow line number and body boundarie try std.testing.expectEqual(Region.gutter, narrow.regionAt(0).?); try std.testing.expectEqual(@as(?Region, null), narrow.regionAt(1)); } - -test "repository selection geometry rejects the empty document synthetic row" { - const allocator = std.testing.allocator; - const bytes = try allocator.dupe(u8, ""); - var document = try source.Document.initOwned(allocator, bytes, .init(bytes)); - defer document.deinit(allocator); - const geometry = SourceGeometry.init(.{ .width = 20, .height = 4 }, &document, true); - try std.testing.expectEqual(@as(usize, 1), document.rowCount()); - try std.testing.expectEqual(@as(usize, 0), document.contentLineCount()); - try std.testing.expectEqual(@as(?usize, null), geometry.contentLineAt(geometry.body_first_row, 0, &document)); -} diff --git a/src/app/pages/repository/source_layout.zig b/src/app/pages/repository/source_layout.zig new file mode 100644 index 0000000..81db462 --- /dev/null +++ b/src/app/pages/repository/source_layout.zig @@ -0,0 +1,242 @@ +//! Borrowed Repository rows shared by drawing, navigation and pointer mapping. +const std = @import("std"); +const projection = @import("chasen_ui").text_projection; +const wrap = @import("../../../line_wrap.zig"); +const source = @import("../../../repository/source.zig"); +const model = @import("model.zig"); +const geometry = @import("source_geometry.zig"); + +pub const Row = struct { + line: usize, + ordinal: usize, + text: projection.Projection, + fragment: ?wrap.Fragment, + + pub fn position(self: Row, document: *const source.Document) model.SourcePosition { + const fragment = self.fragment orelse return .{ .line = self.line }; + if (!fragment.continuation) return .{ .line = self.line }; + var tokens = fragment.tokens; + const token = tokens.next(); + return .{ .line = self.line, .intra = .{ + .byte = if (token) |value| value.byte_start else fragment.bytes.start, + .tab_cells = if (token) |value| fragment.cells.start -| value.cell_start else 0, + .fingerprint = document.fingerprint, + } }; + } + + pub fn segments(self: Row, horizontal: usize, width: usize) wrap.Segments { + return if (self.fragment) |fragment| fragment.segments() else .{ .projected = self.text.visibleSegments(horizontal, width) }; + } + + pub fn hit(self: Row, horizontal: usize, cell: usize) ?projection.CellHit { + return if (self.fragment) |fragment| fragment.hitCell(cell) else self.text.hitViewportCell(horizontal, cell); + } + + pub fn leadingByte(self: Row) usize { + return if (self.fragment) |fragment| fragment.bytes.start else 0; + } +}; + +pub const Layout = struct { + document: *const source.Document, + width: usize, + wrapped: bool, + + pub fn init(document: *const source.Document, viewer: model.ViewerState, pane: geometry.SourceGeometry) Layout { + return .{ .document = document, .width = pane.text_width, .wrapped = viewer.line_wrap }; + } + + pub fn rows(self: Layout, position: model.SourcePosition) Rows { + var top = position.admitted(self.document); + if (!self.wrapped) top.intra = null; + return .{ .layout = self, .line = top.line, .start = top }; + } + + /// Navigation borrows only fully validated admitted documents. + fn first(self: Layout, position: model.SourcePosition) Row { + var iterator = self.rows(position); + return (iterator.next() catch unreachable).?; + } + + pub fn contentRowAt(self: Layout, pane: geometry.SourceGeometry, top: model.SourcePosition, screen_row: u16) ?Row { + if (screen_row < pane.body_first_row or screen_row >= pane.height) return null; + var iterator = self.rows(top); + for (0..screen_row - pane.body_first_row) |_| _ = (iterator.next() catch return null) orelse return null; + const row = (iterator.next() catch return null) orelse return null; + return if (row.line < self.document.contentLineCount()) row else null; + } + + fn rowAtOrdinal(self: Layout, line: usize, ordinal: usize) Row { + var iterator = self.rows(.{ .line = line }); + var row = (iterator.next() catch unreachable).?; + for (0..ordinal) |_| row = (iterator.next() catch unreachable).?; + return row; + } + + fn lastInLine(self: Layout, line: usize) Row { + var iterator = self.rows(.{ .line = line }); + var last = (iterator.next() catch unreachable).?; + if (!self.wrapped) return last; + while (!iterator.fragments.?.done) { + last = (iterator.next() catch unreachable).?; + } + return last; + } + + /// Walk only the affected lines/fragments; no document-wide row table. + pub fn shift(self: Layout, position: model.SourcePosition, direction: isize, count: usize) model.SourcePosition { + if (direction >= 0) { + var iterator = self.rows(position); + var row = (iterator.next() catch unreachable).?; + for (0..count) |_| row = (iterator.next() catch unreachable) orelse break; + return row.position(self.document); + } + var row = self.first(position); + var remaining = count; + while (remaining > row.ordinal) { + remaining -= row.ordinal; + if (row.line == 0) return .{}; + remaining -= 1; + row = self.lastInLine(row.line - 1); + } + return self.rowAtOrdinal(row.line, row.ordinal - remaining).position(self.document); + } + + fn sameRow(a: Row, b: Row) bool { + return a.line == b.line and a.ordinal == b.ordinal; + } + + pub fn clamp(self: Layout, position: model.SourcePosition, visible_rows: usize) model.SourcePosition { + var admitted = position.admitted(self.document); + if (!self.wrapped) { + admitted.intra = null; + admitted.line = @min(admitted.line, self.document.rowCount() -| @max(visible_rows, 1)); + return admitted; + } + // A full visible window already proves the top is within bounds. + // Inspect the tail only when this window actually reaches it. + var iterator = self.rows(admitted); + const first_row = (iterator.next() catch unreachable).?; + var available: usize = 1; + const rows_needed = @max(visible_rows, 1); + while (available < rows_needed) : (available += 1) { + if ((iterator.next() catch unreachable) == null) { + const earlier = self.shift(admitted, -1, rows_needed - available); + return if (sameRow(first_row, self.first(earlier))) admitted else earlier; + } + } + // Reflow must not replace the saved token by its new fragment start. + return admitted; + } + + pub fn scroll(self: Layout, position: model.SourcePosition, direction: isize, count: usize, visible_rows: usize) model.SourcePosition { + const top = self.clamp(position, visible_rows); + if (direction == 0) return top; + const next = self.clamp(self.shift(top, direction, count), visible_rows); + return if (sameRow(self.first(top), self.first(next))) top else next; + } + + pub fn visibleLines(self: Layout, top: model.SourcePosition, visible_rows: usize) struct { first: usize, last: usize } { + var iterator = self.rows(top); + const first_row = (iterator.next() catch unreachable).?; + var last = first_row.line; + for (1..@max(visible_rows, 1)) |_| { + const row = (iterator.next() catch unreachable) orelse break; + last = row.line; + } + return .{ .first = first_row.line, .last = last }; + } + + /// Explicit navigation reveals a fragment, with the existing comfort band. + pub fn reveal(self: Layout, top: model.SourcePosition, target: model.SourcePosition, visible_rows: usize, centered: bool) model.SourcePosition { + if (visible_rows == 0) return self.clamp(top, visible_rows); + const target_row = self.first(target); + const margin = @min(@as(usize, 8), visible_rows / 3); + const first_band = if (centered) visible_rows / 2 else margin; + const last_band = if (centered) first_band else visible_rows - 1 - margin; + var iterator = self.rows(top); + for (0..visible_rows) |index| { + const row = (iterator.next() catch unreachable) orelse break; + if (sameRow(row, target_row)) { + if (index >= first_band and index <= last_band) return self.clamp(top, visible_rows); + return self.clamp(self.shift(target, -1, if (index < first_band) first_band else last_band), visible_rows); + } + } + const current = self.first(top); + const before = target_row.line < current.line or + (target_row.line == current.line and target_row.ordinal < current.ordinal); + return self.clamp(self.shift(target, -1, if (before) first_band else last_band), visible_rows); + } +}; + +pub const Rows = struct { + layout: Layout, + line: usize, + start: model.SourcePosition, + text: ?projection.Projection = null, + fragments: ?wrap.Iterator = null, + ordinal: usize = 0, + + pub fn next(self: *Rows) projection.Error!?Row { + while (self.line < self.layout.document.rowCount()) { + if (self.text == null) { + self.text = try projection.Projection.init(self.layout.document.lineBody(self.line).?, .{ .tab_width = 4 }); + self.ordinal = 0; + if (self.layout.wrapped) self.fragments = wrap.Iterator.init(self.text.?, self.layout.width); + } + if (self.fragments) |*fragments| { + const target_cell = if (self.start.intra) |intra| + self.text.?.leadingCellForByte(intra.byte) +| intra.tab_cells + else + 0; + while (fragments.next()) |fragment| { + const ordinal = self.ordinal; + self.ordinal += 1; + if (self.start.intra != null and !fragment.placeholder and + fragment.cells.end <= target_cell and !fragments.done) continue; + self.start.intra = null; + return .{ .line = self.line, .ordinal = ordinal, .text = self.text.?, .fragment = fragment }; + } + } else { + const row: Row = .{ .line = self.line, .ordinal = 0, .text = self.text.?, .fragment = null }; + self.line += 1; + self.text = null; + return row; + } + self.line += 1; + self.text = null; + self.fragments = null; + } + return null; + } +}; + +test "repository wrap reflow preserves token TAB anchors and excludes chrome and empty hits" { + const allocator = std.testing.allocator; + const bytes = try allocator.dupe(u8, "a\tbcdefghijklmnop\nlast\n"); + var document = try source.Document.initOwnedOrFree(allocator, bytes, .init(bytes)); + defer document.deinit(allocator); + var viewer: model.ViewerState = .{ .line_wrap = true }; + const narrow = geometry.SourceGeometry.init(.{ .width = 4, .height = 4 }, &document, false); + const layout = Layout.init(&document, viewer, narrow); + viewer.source_vertical_scroll = layout.scroll(.{}, 1, 1, 1); + const anchor = viewer.source_vertical_scroll; + try std.testing.expectEqual(@as(usize, 1), anchor.intra.?.byte); + try std.testing.expectEqual(@as(usize, 1), anchor.intra.?.tab_cells); + const wide = geometry.SourceGeometry.init(.{ .width = 7, .height = 4 }, &document, false); + try std.testing.expectEqualDeep(anchor, Layout.init(&document, viewer, wide).clamp(anchor, 1)); + const zero = geometry.SourceGeometry.init(.{ .width = 1, .height = 8 }, &document, false); + try std.testing.expectEqualDeep(anchor, Layout.init(&document, viewer, zero).clamp(anchor, zero.visible_source_rows)); + const hidden = Layout.init(&document, viewer, zero).contentRowAt(zero, anchor, zero.body_first_row).?; + try std.testing.expect(hidden.hit(0, 0) == null); + const restored = layout.contentRowAt(narrow, anchor, narrow.body_first_row).?; + try std.testing.expectEqual(@as(usize, 2), restored.fragment.?.cells.start); + for (0..narrow.body_first_row) |row| try std.testing.expect(layout.contentRowAt(narrow, anchor, @intCast(row)) == null); + try std.testing.expect(layout.contentRowAt(narrow, anchor, narrow.height) == null); + const empty_bytes = try allocator.dupe(u8, ""); + var empty = try source.Document.initOwnedOrFree(allocator, empty_bytes, .init(empty_bytes)); + defer empty.deinit(allocator); + const empty_layout = Layout.init(&empty, viewer, narrow); + try std.testing.expect(empty_layout.contentRowAt(narrow, .{}, narrow.body_first_row) == null); + try std.testing.expectEqualDeep(model.SourcePosition{}, empty_layout.scroll(.{}, 1, 10, 1)); +} diff --git a/src/app/pages/repository/view.zig b/src/app/pages/repository/view.zig index 1564a32..d97bb4d 100644 --- a/src/app/pages/repository/view.zig +++ b/src/app/pages/repository/view.zig @@ -22,6 +22,8 @@ const model = @import("model.zig"); const selection = @import("selection.zig"); const source_header = @import("source_header.zig"); const source_geometry = @import("source_geometry.zig"); +const source_layout = @import("source_layout.zig"); +const line_wrap = @import("../../../line_wrap.zig"); const source = @import("../../../repository/source.zig"); const repository_change_map = @import("../../../repository/change_map.zig"); const repository_change_index = @import("../../../repository/change_index.zig"); @@ -618,67 +620,41 @@ pub fn drawSourceWithRetained( const active_keyboard = if (live_selection) |live| live.origin == .keyboard_line else false; const visible_retained = if (active_keyboard) null else retained_selection; - const rows = geometry.navigationRows(); + var rows = source_layout.Layout.init(document, viewer, geometry).rows(viewer.source_vertical_scroll); var body_row: usize = 0; - while (body_row < rows and viewer.source_vertical_scroll + body_row < document.rowCount()) : (body_row += 1) { - const line_index = viewer.source_vertical_scroll + body_row; + var previous_line: ?usize = null; + while (body_row < geometry.visible_source_rows) : (body_row += 1) { + const source_row = (try rows.next()) orelse break; + const line_index = source_row.line; const row = geometry.body_first_row + @as(u16, @intCast(body_row)); - const line = document.lineBody(line_index).?; - const projection = try text_projection.Projection.init(line, .{ .tab_width = repository_tab_width }); const current = line_index == viewer.source_cursor; if (source_active and current) fillSourceCursorRow(surface, row, palette.color(.pane_cursor_bg)); const base_style = sourceRowStyle(palette.style(.foreground), source_active, current, palette); - const cursor_style = sourceGutterStyle(palette.boldStyle(.diff_cursor), source_active, current, palette); - _ = surface.borrowTextAt(geometry.cursor_col, row, if (current) "▌" else " ", cursor_style); - - const change = if (changes) |map| map.row(line_index) else .none; - const gutter: []const u8 = if (change == .none) " " else "▌"; + _ = surface.borrowTextAt(geometry.cursor_col, row, if (current and previous_line != line_index) "▌" else " ", cursor_style); + previous_line = line_index; + const first_fragment = source_row.ordinal == 0; + const change = if (first_fragment) if (changes) |map| map.row(line_index) else .none else .none; const gutter_role: theme.Role = switch (change) { .none => .foreground, .added => .diff_added, .modified => .diff_modified, }; - const gutter_style = sourceGutterStyle(palette.style(gutter_role), source_active, current, palette); - _ = surface.borrowTextAt(geometry.gutter_col, row, gutter, gutter_style); - if (viewer.line_numbers) { + _ = surface.borrowTextAt(geometry.gutter_col, row, if (change == .none) " " else "▌", sourceGutterStyle(palette.style(gutter_role), source_active, current, palette)); + if (viewer.line_numbers and first_fragment) { const number = try std.fmt.allocPrint(surface.frameAllocator(), "{d}", .{line_index + 1}); const number_col: u16 = @intCast(@as(usize, geometry.line_number_col) + geometry.line_number_width - chasen.text.displayWidth(number)); const number_role: theme.Role = if (source_active and current) .pane_active_line_number else .diff_line_number; draw.copyClippedTextAt(surface, number_col, row, number, sourceRowStyle(palette.style(number_role), source_active, current, palette)) catch {}; } - if (geometry.text_width == 0) { - applyCompletedSelectionLineStyles(surface, geometry, row, document, line_index, projection, viewer.source_horizontal_scroll, visible_retained, palette.color(.diff_selection_bg)); - applySelectionLineStyles(surface, geometry, row, document, line_index, projection, viewer.source_horizontal_scroll, live_selection, palette.color(.diff_selection_bg)); - continue; - } - drawProjectedLine(surface, geometry.text_col, row, projection, viewer.source_horizontal_scroll, geometry.text_width, base_style); - if (syntax) |spans| applySyntaxLineStyles( - surface, - geometry.text_col, - row, - projection, - viewer.source_horizontal_scroll, - geometry.text_width, - spans.lineSpans(line_index), - base_style, - palette, - null, - ); + const segments = source_row.segments(viewer.source_horizontal_scroll, geometry.text_width); + drawProjectedLine(surface, geometry.text_col, row, segments, base_style); + if (syntax) |spans| applySyntaxLineStyles(surface, geometry.text_col, row, segments, spans.lineSpans(line_index), base_style, palette, null); if (search.match) |match| if (match.line == line_index) { - applyByteRangeStyle( - surface, - geometry.text_col, - row, - projection, - viewer.source_horizontal_scroll, - geometry.text_width, - .{ .start = match.start, .end = match.end }, - sourceRowStyle(palette.boldStyle(.warning), source_active, current, palette), - ); + applyByteRangeStyle(surface, geometry.text_col, row, segments, .{ .start = match.start, .end = match.end }, sourceRowStyle(palette.boldStyle(.warning), source_active, current, palette)); }; - applyCompletedSelectionLineStyles(surface, geometry, row, document, line_index, projection, viewer.source_horizontal_scroll, visible_retained, palette.color(.diff_selection_bg)); - applySelectionLineStyles(surface, geometry, row, document, line_index, projection, viewer.source_horizontal_scroll, live_selection, palette.color(.diff_selection_bg)); + applyCompletedSelectionLineStyles(surface, geometry, row, document, line_index, source_row.text.line.len, segments, visible_retained, palette.color(.diff_selection_bg)); + applySelectionLineStyles(surface, geometry, row, document, line_index, source_row.text.line.len, segments, live_selection, palette.color(.diff_selection_bg)); } } @@ -686,12 +662,10 @@ fn drawProjectedLine( surface: *chasen.Surface, text_col: u16, row: u16, - projection: text_projection.Projection, - horizontal_scroll: usize, - width: usize, + segments: line_wrap.Segments, style: chasen.TextStyle, ) void { - var visible = projection.visibleSegments(horizontal_scroll, width); + var visible = segments; while (visible.next()) |segment| drawVisibleSegment(surface, text_col, row, segment, style); } @@ -715,14 +689,12 @@ fn applyByteRangeStyle( surface: *chasen.Surface, text_col: u16, row: u16, - projection: text_projection.Projection, - horizontal_scroll: usize, - width: usize, + segments: line_wrap.Segments, range: text_projection.ByteRange, style: chasen.TextStyle, ) void { if (range.start >= range.end) return; - var visible = projection.visibleSegments(horizontal_scroll, width); + var visible = segments; while (visible.next()) |segment| { if (segment.token.byte_start >= range.end) break; if (segment.token.byte_end <= range.start) continue; @@ -736,8 +708,8 @@ fn applyCompletedSelectionLineStyles( row: u16, document: *const source.Document, line_index: usize, - projection: text_projection.Projection, - horizontal_scroll: usize, + line_len: usize, + segments: line_wrap.Segments, retained_selection: ?*const selection.CompletedSelection, background: chasen.Color, ) void { @@ -748,8 +720,8 @@ fn applyCompletedSelectionLineStyles( geometry, row, line_index, - projection, - horizontal_scroll, + line_len, + segments, selected.mode, selected.range, background, @@ -762,8 +734,8 @@ fn applySelectionLineStyles( row: u16, document: *const source.Document, line_index: usize, - projection: text_projection.Projection, - horizontal_scroll: usize, + line_len: usize, + segments: line_wrap.Segments, live_selection: ?selection.DragSelection, background: chasen.Color, ) void { @@ -774,8 +746,8 @@ fn applySelectionLineStyles( geometry, row, line_index, - projection, - horizontal_scroll, + line_len, + segments, selected.mode, selected.range(), background, @@ -787,8 +759,8 @@ fn applySelectionRangeLineStyles( geometry: source_geometry.SourceGeometry, row: u16, line_index: usize, - projection: text_projection.Projection, - horizontal_scroll: usize, + line_len: usize, + segments: line_wrap.Segments, mode: selection.Mode, range: selection.Range, background: chasen.Color, @@ -801,9 +773,9 @@ fn applySelectionRangeLineStyles( } const byte_start = if (line_index == range.start.line_index) range.start.leading_byte else 0; - const byte_end = if (line_index == range.end.line_index) range.end.trailing_byte else projection.line.len; + const byte_end = if (line_index == range.end.line_index) range.end.trailing_byte else line_len; if (byte_start >= byte_end or geometry.text_width == 0) return; - var visible = projection.visibleSegments(horizontal_scroll, geometry.text_width); + var visible = segments; while (visible.next()) |segment| { if (segment.token.byte_start >= byte_end) break; if (segment.token.byte_end <= byte_start) continue; @@ -847,17 +819,24 @@ fn applySyntaxLineStyles( surface: *chasen.Surface, text_col: u16, row: u16, - projection: text_projection.Projection, - horizontal_scroll: usize, - width: usize, + segments: line_wrap.Segments, line_spans: syntax_token.LineSpans, base_style: chasen.TextStyle, palette: theme.Palette, stats: ?*SyntaxProjectionStats, ) void { - if (width == 0 or line_spans.spans.len == 0) return; - var span_index: usize = 0; - var visible = projection.visibleSegments(horizontal_scroll, width); + if (line_spans.spans.len == 0) return; + var visible = segments; + var peek = segments; + const first = peek.next() orelse return; + // Ordered byte spans: seek once for each visible fragment, then advance. + var low: usize = 0; + var high = line_spans.spans.len; + while (low < high) { + const middle = low + (high - low) / 2; + if (line_spans.spans[middle].end <= first.token.byte_start) low = middle + 1 else high = middle; + } + var span_index = low; while (visible.next()) |segment| { if (stats) |value| value.segments_visited += 1; while (span_index < line_spans.spans.len and line_spans.spans[span_index].end <= segment.token.byte_start) { @@ -1784,6 +1763,50 @@ test "repository selection background composes after cursor syntax and search st try std.testing.expect(trailing.style.bg.eql(palette.color(.pane_cursor_bg))); } +test "repository wrap draws continuation syntax search selection and narrow replacement" { + const allocator = std.testing.allocator; + const bytes = try allocator.dupe(u8, "abcd界\tneedle e\u{301}👩‍💻END\nnext\n"); + var document = try source.Document.initOwnedOrFree(allocator, bytes, .init(bytes)); + defer document.deinit(allocator); + var candidates = [_]source_syntax.Candidate{ + .{ .line_index = 0, .span = .{ .start = 4, .end = 7, .role = .keyword } }, + }; + var spans = try source_syntax.build(allocator, &document, &candidates); + defer spans.deinit(allocator); + const token: selection.RepositoryContentToken = .{ + .repo_epoch = 1, + .root_identity = .{ .device = 2, .inode = 3 }, + .path = "main.zig", + .source_fingerprint = document.fingerprint, + }; + var live = selection.DragSelection.init(token, .character, selection.pointFromBoundary(0, 4)); + live.update(selection.pointFromBoundary(0, document.lineBody(0).?.len)); + const palette: theme.Palette = .default(); + var surface: chasen.testing.TestSurface = undefined; + try surface.init(8, 10); + defer surface.deinit(); + var viewer: model.ViewerState = .{ .focus = .source, .line_wrap = true }; + try drawSource(&surface.surface, &document, &spans, null, viewer, .{ .match = document.findNext("needle", null) }, live, palette); + try surface.expectCellText(4, 4, "界"); + try surface.expectCellText(4, 5, "n"); + try surface.expectCellText(2, 4, " "); + try surface.expectCellText(0, 3, "▌"); + try surface.expectCellText(0, 4, " "); + try std.testing.expectEqual(palette.color(.syntax_keyword), surface.surface.readCell(4, 4).?.style.fg); + try std.testing.expectEqual(palette.color(.warning), surface.surface.readCell(4, 5).?.style.fg); + try std.testing.expectEqual(palette.color(.diff_selection_bg), surface.surface.readCell(4, 5).?.style.bg); + var narrow: chasen.testing.TestSurface = undefined; + try narrow.init(5, 5); + defer narrow.deinit(); + viewer.source_vertical_scroll = .{ .intra = .{ .byte = 4, .fingerprint = document.fingerprint } }; + try drawSource(&narrow.surface, &document, &spans, null, viewer, .{}, live, palette); + try narrow.expectCellText(4, 3, "�"); + try narrow.expectCellText(0, 3, "▌"); + try narrow.expectCellText(2, 3, " "); + try std.testing.expectEqual(palette.color(.syntax_keyword), narrow.surface.readCell(4, 3).?.style.fg); + try std.testing.expectEqual(palette.color(.diff_selection_bg), narrow.surface.readCell(4, 3).?.style.bg); +} + test "repository selection whole-line style covers gutter numbers body and trailing cells" { const allocator = std.testing.allocator; const bytes = try allocator.dupe(u8, "one\ntwo\n"); @@ -1938,15 +1961,13 @@ test "repository source syntax projection visits dense line and spans only once" defer test_surface.deinit(); const horizontal_scroll = count - 80; const projection = try text_projection.Projection.init(line, .{ .tab_width = repository_tab_width }); - drawProjectedLine(&test_surface.surface, 0, 0, projection, horizontal_scroll, 80, .{}); + drawProjectedLine(&test_surface.surface, 0, 0, .{ .projected = projection.visibleSegments(horizontal_scroll, 80) }, .{}); var stats: SyntaxProjectionStats = .{}; applySyntaxLineStyles( &test_surface.surface, 0, 0, - projection, - horizontal_scroll, - 80, + .{ .projected = projection.visibleSegments(horizontal_scroll, 80) }, .{ .spans = spans }, .{}, .default(), @@ -1972,16 +1993,16 @@ test "repository source syntax projection preserves tab and clipped-wide cells" try tab_surface.init(3, 1); defer tab_surface.deinit(); const projection = try text_projection.Projection.init(line, .{ .tab_width = repository_tab_width }); - drawProjectedLine(&tab_surface.surface, 0, 0, projection, 1, 3, .{}); - applySyntaxLineStyles(&tab_surface.surface, 0, 0, projection, 1, 3, .{ .spans = &spans }, .{}, palette, null); + drawProjectedLine(&tab_surface.surface, 0, 0, .{ .projected = projection.visibleSegments(1, 3) }, .{}); + applySyntaxLineStyles(&tab_surface.surface, 0, 0, .{ .projected = projection.visibleSegments(1, 3) }, .{ .spans = &spans }, .{}, palette, null); try std.testing.expectEqual(palette.color(.accent), tab_surface.surface.readCell(0, 0).?.style.fg); try std.testing.expectEqual(palette.color(.accent), tab_surface.surface.readCell(2, 0).?.style.fg); var wide_surface: chasen.testing.TestSurface = undefined; try wide_surface.init(2, 1); defer wide_surface.deinit(); - drawProjectedLine(&wide_surface.surface, 0, 0, projection, 5, 2, .{}); - applySyntaxLineStyles(&wide_surface.surface, 0, 0, projection, 5, 2, .{ .spans = &spans }, .{}, palette, null); + drawProjectedLine(&wide_surface.surface, 0, 0, .{ .projected = projection.visibleSegments(5, 2) }, .{}); + applySyntaxLineStyles(&wide_surface.surface, 0, 0, .{ .projected = projection.visibleSegments(5, 2) }, .{ .spans = &spans }, .{}, palette, null); try std.testing.expectEqualStrings(" ", wide_surface.surface.readCell(0, 0).?.char.grapheme); try std.testing.expectEqual(palette.color(.success), wide_surface.surface.readCell(0, 0).?.style.fg); try std.testing.expectEqualStrings("x", wide_surface.surface.readCell(1, 0).?.char.grapheme); diff --git a/src/app/tests/canonical_publication.zig b/src/app/tests/canonical_publication.zig index 8deec18..753d54b 100644 --- a/src/app/tests/canonical_publication.zig +++ b/src/app/tests/canonical_publication.zig @@ -2126,7 +2126,7 @@ pub fn canonicalPublicationTestApp( .selected_target = .{ .diff_file = 0 }, .selected_node = 0, .diff_cursor = .{ .hunk_header = 1 }, - .diff_scroll = 1, + .diff_scroll = .{ .logical = 1 }, .diff_horizontal_scroll = 2, .sidebar_horizontal_scroll = 1, }, @@ -2179,7 +2179,7 @@ fn canonicalPublicationPrimaryTestApp( .selected_target = .{ .diff_file = 0 }, .selected_node = 0, .diff_cursor = .{ .hunk_header = 1 }, - .diff_scroll = 1, + .diff_scroll = .{ .logical = 1 }, .diff_horizontal_scroll = 2, .sidebar_horizontal_scroll = 1, }, @@ -2243,7 +2243,7 @@ fn ordinaryPrimaryPublicationTestApp( .selected_target = .{ .diff_file = 0 }, .selected_node = 0, .diff_cursor = .{ .hunk_header = 0 }, - .diff_scroll = 1, + .diff_scroll = .{ .logical = 1 }, .diff_horizontal_scroll = 2, .sidebar_horizontal_scroll = 1, }, @@ -2623,7 +2623,7 @@ fn expectFreshCanonicalActionCapabilities( else => return error.ExpectedFreshFileUnstageCapability, } - app.pages.changes.viewer.diff_scroll = 0; + app.pages.changes.viewer.diff_scroll = .{ .logical = 0 }; app.pages.changes.viewer.diff_cursor = .{ .hunk_header = 0 }; switch (changesOperations(app).selectedHunkUnstageTarget(allocator)) { .ready => |target| { @@ -2756,7 +2756,7 @@ fn expectFreshCanonicalCachedPublication( else => return error.ExpectedFreshFileUnstageCapability, } - app.pages.changes.viewer.diff_scroll = 0; + app.pages.changes.viewer.diff_scroll = .{ .logical = 0 }; app.pages.changes.viewer.diff_cursor = .{ .hunk_header = 0 }; switch (changesOperations(app).selectedHunkToggleOperation()) { .operation => |operation| try std.testing.expectEqual( diff --git a/src/app/tests/page_transition.zig b/src/app/tests/page_transition.zig index c84b694..dc2d20e 100644 --- a/src/app/tests/page_transition.zig +++ b/src/app/tests/page_transition.zig @@ -68,7 +68,7 @@ test "Compare entry resolves default and picker selection queues its full ref" { var app: App = .{ .allocator = allocator, .config = .{ .source = .{ .patch_file = "change.patch" } }, - .pages = .{ .changes = .{ .viewer = .{ .diff_scroll = 17 } } }, + .pages = .{ .changes = .{ .viewer = .{ .diff_scroll = .{ .logical = 17 } } } }, .repo_session = .{ .repo_state = .{ .discovery = try testSingleRepoDiscovery(allocator, roots.a) }, }, @@ -101,7 +101,7 @@ test "Compare entry resolves default and picker selection queues its full ref" { ) } } }, &ctx.ctx); try std.testing.expectEqualStrings("main", app.pages.compare.basis.?.base.display_name); try std.testing.expect(app.pages.compare.diff.load.state == .loaded); - try std.testing.expectEqual(@as(usize, 17), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 17), app.pages.changes.viewer.diff_scroll.row()); // The production shared-input route must consume the same-owner bound // UpdateAdapter rather than reintroducing a raw controller/resolver pair. @@ -253,6 +253,38 @@ test "Repository active pointer owner blocks wheel redraw until release" { try std.testing.expect(!app.pages.repository.activeMouseOwner()); } +test "line wrap routes to source pages while text and overlays retain input" { + const key: chasen.Key = .{ .codepoint = 'W' }; + for ([_]page.Id{ .changes, .compare, .history, .repository }) |active_page| { + var input_context: app_input.KeyContext = .{ .active_page = active_page, .history = .{ .diff_view = true } }; + const expected: App.Msg = switch (active_page) { + .changes => .{ .changes = .toggle_line_wrap }, + .compare => .{ .compare = .{ .common = .{ .shared = .toggle_line_wrap } } }, + .history => .{ .history = .{ .common = .{ .shared = .toggle_line_wrap } } }, + .repository => .{ .repository = .toggle_line_wrap }, + }; + try std.testing.expectEqual(expected, app_input.keyToMsg(input_context, key).?); + input_context.repository.source_search_mode = true; + input_context.changes.search_mode = true; + input_context.compare.common.search_mode = true; + input_context.history.common.search_mode = true; + const search: App.Msg = switch (active_page) { + .changes => .{ .changes = .{ .search_insert = 'W' } }, + .compare => .{ .compare = .{ .common = .{ .shared = .{ .search_insert = 'W' } } } }, + .history => .{ .history = .{ .common = .{ .shared = .{ .search_insert = 'W' } } } }, + .repository => .{ .repository = .{ .source_search_insert = 'W' } }, + }; + try std.testing.expectEqual(search, app_input.keyToMsg(input_context, key).?); + } + const keymap = @import("keymap"); + var config: keymap.Config = .{}; + config.set(.toggle_line_wrap, .{ .plain_codepoint = 'x' }); + try std.testing.expectEqual(App.Msg{ .repository = .toggle_line_wrap }, app_input.keyToMsg(.{ .active_page = .repository, .keymap = .fromConfig(config), .repository = .{ .keymap = .fromConfig(config) } }, .{ .codepoint = 'x' }).?); + try std.testing.expectEqual(App.Msg{ .repository = .{ .file_search_insert = 'W' } }, app_input.keyToMsg(.{ .active_page = .repository, .repository = .{ .file_search_mode = true } }, key).?); + try std.testing.expect(app_input.keyToMsg(.{ .help_mode = true }, key) == null); + try std.testing.expectEqual(App.Msg{ .commit_panel_insert = 'W' }, app_input.keyToMsg(.{ .commit_panel_mode = true }, key).?); +} + test "keyboard and page bar mouse share the page switch transition" { var app: App = .{ .allocator = std.testing.allocator, @@ -298,7 +330,7 @@ test "page key 4 activates Compare without replacing retained Changes state" { .config = .{ .source = .{ .patch_file = "change.patch" } }, .pages = .{ .changes = .{ .load = .{ .state = .{ .empty = .no_changes } }, - .viewer = .{ .diff_scroll = 11 }, + .viewer = .{ .diff_scroll = .{ .logical = 11 }, .view_options = .{ .line_wrap = true } }, } }, }; _ = activateChanges(&app); @@ -318,7 +350,9 @@ test "page key 4 activates Compare without replacing retained Changes state" { app.pages.compare.activation.currentIdentity().?.origin, ); try std.testing.expect(app.pages.changes.activation.state == .inactive); - try std.testing.expectEqual(@as(usize, 11), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 11), app.pages.changes.viewer.diff_scroll.row()); + try std.testing.expect(app.pages.changes.viewer.view_options.line_wrap); + try std.testing.expect(!app.pages.compare.diff.viewer.view_options.line_wrap); try std.testing.expect(app.pages.changes.load.state == .empty); try std.testing.expect(!app.redraw_plan.resolvesToSkip()); try std.testing.expectEqual(@as(usize, 0), ctx.pendingTaskCount()); @@ -392,12 +426,14 @@ test "changes repository transition common switch commits exact path before reva .viewer = .{ .selected_target = .{ .diff_file = 0 }, .selected_node = 0, + .view_options = .{ .line_wrap = true }, }, }, .repository = .{ .active = true, .repo_epoch = 7, .selected_path = "b", + .viewer = .{ .line_wrap = true }, }, }, }; @@ -415,8 +451,10 @@ test "changes repository transition common switch commits exact path before reva try std.testing.expectEqual(page.Id.changes, app.active_page); try std.testing.expect(!app.pages.repository.active); + try std.testing.expect(app.pages.repository.viewer.line_wrap); try std.testing.expectEqual(@as(usize, 1), app.pages.changes.viewer.selected_node); try std.testing.expectEqual(context.SelectedTarget{ .diff_file = 1 }, app.pages.changes.viewer.selected_target.?); + try std.testing.expect(app.pages.changes.viewer.view_options.line_wrap); try std.testing.expectEqual(@as(usize, 0), app.status.text().len); try std.testing.expectEqual(@as(usize, 3), ctx.pendingTaskCount()); const active = app.pages.changes.activation.state.active; @@ -455,7 +493,7 @@ test "changes repository transition blocker retains page owner and Changes state .viewer = .{ .selected_target = .{ .diff_file = 0 }, .selected_node = 0, - .diff_scroll = 6, + .diff_scroll = .{ .logical = 6 }, }, }, .repository = .{ @@ -488,7 +526,7 @@ test "changes repository transition blocker retains page owner and Changes state try std.testing.expectEqualStrings("pending.zig", app.pages.repository.incoming.manifestIntent().?.path); try std.testing.expect(app.pages.changes.activation.state == .inactive); try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.selected_node); - try std.testing.expectEqual(@as(usize, 6), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 6), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqualStrings("finish file search before switching pages", app.status.text()); try std.testing.expectEqual(@as(usize, 0), ctx.pendingTaskCount()); } @@ -654,7 +692,7 @@ test "changes repository transition common switch maps retained-location outcome .selected_target = .{ .diff_file = case.retained_index }, .selected_node = case.retained_index, .diff_cursor = if (case.retained_index == 0) .{ .metadata = 0 } else .{ .metadata = 1 }, - .diff_scroll = 9, + .diff_scroll = .{ .logical = 9 }, }, .review_display = .{ .hide_reviewed_files = case.hide_reviewed }, }, @@ -691,7 +729,7 @@ test "changes repository transition common switch maps retained-location outcome diff_view_model.BodyCoordinate{ .metadata = 1 }, app.pages.changes.viewer.diff_cursor, )); - try std.testing.expectEqual(@as(usize, 9), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 9), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqual(case.hide_reviewed, app.pages.changes.review_display.hide_reviewed_files); try std.testing.expectEqualStrings(case.status, app.status.text()); try std.testing.expectEqual(@as(usize, 3), ctx.pendingTaskCount()); @@ -717,7 +755,7 @@ test "changes repository transition common switch consumes pending and unavailab .viewer = .{ .selected_target = .{ .diff_file = 0 }, .selected_node = 0, - .diff_scroll = 6, + .diff_scroll = .{ .logical = 6 }, }, }, .repository = .{ @@ -753,7 +791,7 @@ test "changes repository transition common switch consumes pending and unavailab try std.testing.expect(!app.pages.repository.active); try std.testing.expect(app.pages.repository.incoming == .none); try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.selected_node); - try std.testing.expectEqual(@as(usize, 6), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 6), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expectEqualStrings("Repository has no resolved file to open in Changes", app.status.text()); try std.testing.expectEqual(@as(usize, 1), ctx.pendingTaskCount()); } diff --git a/src/app/tests/remote_shell.zig b/src/app/tests/remote_shell.zig index 9397693..2bfba28 100644 --- a/src/app/tests/remote_shell.zig +++ b/src/app/tests/remote_shell.zig @@ -2251,7 +2251,7 @@ test "History and Compare branch switch terminals keep caller intent and retire const diff = if (owner == .history) &history.diff else &compare.diff; diff.viewer.selected_target = .{ .diff_file = 0 }; diff.viewer.diff_cursor = .{ .hunk_header = 0 }; - diff.viewer.diff_scroll = 2; + diff.viewer.diff_scroll = .{ .logical = 2 }; const old_identity = if (owner == .history) history.activation.currentIdentity().? else compare.activation.currentIdentity().?; if (owner == .history) { history.armDiff(.{ .identity = old_identity, .root_identity = root_identity, .generation = 10, .request = history.accepted.?.request }); @@ -2288,7 +2288,7 @@ test "History and Compare branch switch terminals keep caller intent and retire try std.testing.expect(history.activation.state.satisfiesAction(.read_diff)); try std.testing.expect(history.accepted.?.request.basis.after.eql(&selected)); try std.testing.expect(history.accepted.?.request.basis.before.commit.eql(&before)); - try std.testing.expectEqual(@as(usize, 2), diff.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 2), diff.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(usize, 0), diff.viewer.diff_cursor.hunk_header); const probe = history.pending.?.catalog; try std.testing.expectEqual(if (success) app_load.HistoryProbeReason.reload else .activation, probe.request.probe); diff --git a/src/app/tests/root_shell.zig b/src/app/tests/root_shell.zig index d2e14e7..cb29c16 100644 --- a/src/app/tests/root_shell.zig +++ b/src/app/tests/root_shell.zig @@ -300,12 +300,12 @@ test "root drag auto-scroll replaces generations and rejects stale ticks" { // A queued firing from the canceled timer is inert. B performs exactly // one row of semantic scroll and remains the repeating owner. tc.resetTransient(); - app.pages.changes.viewer.diff_scroll = 1; + app.pages.changes.viewer.diff_scroll = .{ .logical = 1 }; app.status.set("root diagnostic", .{}); app.pages.changes.status.set("changes diagnostic", .{}); const selection_before_stale = app.pages.changes.selection_owner; try app.update(.{ .drag_auto_scroll_tick = first_generation }, &tc.ctx); - try std.testing.expectEqual(@as(usize, 1), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 1), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expect(std.meta.eql(selection_before_stale, app.pages.changes.selection_owner)); try std.testing.expectEqualStrings("root diagnostic", app.status.text()); try std.testing.expectEqualStrings("changes diagnostic", app.pages.changes.status.text()); @@ -313,7 +313,7 @@ test "root drag auto-scroll replaces generations and rejects stale ticks" { tc.resetTransient(); try app.update(.{ .drag_auto_scroll_tick = second_generation }, &tc.ctx); - try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expect(app.drag_auto_scroll.active != null); // Reversing once more admits C. Repeated firings from that one accepted @@ -412,12 +412,12 @@ test "root drag auto-scroll replaces generations and rejects stale ticks" { .anchor_cell = .{ .col = 12, .row = diff_render.body_start_row }, } }; const focus_before_action = app.pages.changes.selection_owner.activeDiff().?.focus; - app.pages.changes.viewer.diff_scroll = 2; + app.pages.changes.viewer.diff_scroll = .{ .logical = 2 }; try app.update(.{ .changes = .{ .mouse_diff_auto_scroll_step = .{ .direction = .up, .endpoint = .{ .col = 12, .row = 1 }, } } }, &tc.ctx); - try std.testing.expectEqual(@as(usize, 1), app.pages.changes.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 1), app.pages.changes.viewer.diff_scroll.row()); try std.testing.expect(std.meta.eql(focus_before_action, app.pages.changes.selection_owner.activeDiff().?.focus)); } @@ -479,7 +479,7 @@ test "root drag auto-scroll timer failures retain only retryable authority" { tc.resetTransient(); var adapter = changesNavigation(&app).updateAdapter(); const body_view = adapter.shared().navigation.view(); - app.pages.changes.viewer.diff_scroll = body_view.presentationDiffLineCount() -| body_view.view.diffVisibleRows(); + app.pages.changes.viewer.diff_scroll = .{ .logical = body_view.presentationDiffLineCount() -| body_view.view.diffVisibleRows() }; try app.update(.{ .drag_auto_scroll_tick = 12 }, &tc.ctx); try std.testing.expect(app.drag_auto_scroll.active == null); try std.testing.expect(app.drag_auto_scroll.scheduled_generation == null); @@ -915,7 +915,7 @@ test "Compare retained actions route keyboard and mouse through App after narrow const after_keyboard_body = after_keyboard_view.bodyView(&after_keyboard_resolver); const expected_keyboard_scroll = after_keyboard_body.restoreSelectionViewportAnchor(keyboard_anchor); try std.testing.expectEqual(keyboard_anchor.raw_presentation_scroll, expected_keyboard_scroll); - try std.testing.expectEqual(expected_keyboard_scroll, app.pages.compare.diff.viewer.diff_scroll); + try std.testing.expectEqual(expected_keyboard_scroll, app.pages.compare.diff.viewer.diff_scroll.row()); try installRootCompareSelection(&app, allocator); var mouse_clear_view = compareNavigation(&app).view(); @@ -933,7 +933,7 @@ test "Compare retained actions route keyboard and mouse through App after narrow const after_mouse_body = after_mouse_view.bodyView(&after_mouse_resolver); const expected_mouse_scroll = after_mouse_body.restoreSelectionViewportAnchor(mouse_anchor); try std.testing.expectEqual(mouse_anchor.raw_presentation_scroll, expected_mouse_scroll); - try std.testing.expectEqual(expected_mouse_scroll, app.pages.compare.diff.viewer.diff_scroll); + try std.testing.expectEqual(expected_mouse_scroll, app.pages.compare.diff.viewer.diff_scroll.row()); try std.testing.expectEqual(@as(usize, 0), app.shell_effects_state.clipboard_copies.count()); } @@ -1078,7 +1078,7 @@ test "History accepted diff runs one root interaction and transition sequence" { accepted_loaded.toggleHunkFold(0, 0); app.pages.history.diff.viewer.display_mode = .side_by_side; app.pages.history.diff.viewer.diff_cursor = .{ .hunk_header = 1 }; - app.pages.history.diff.viewer.diff_scroll = 4; + app.pages.history.diff.viewer.diff_scroll = .{ .logical = 4 }; const retained_target = app.pages.history.diff.viewer.selected_target; const retained_node = app.pages.history.diff.viewer.selected_node; try std.testing.expect(file_tree.isCollapsed(&accepted_loaded.collapsed_dirs, "src")); @@ -1095,7 +1095,7 @@ test "History accepted diff runs one root interaction and transition sequence" { try std.testing.expectEqual(@as(usize, 2), app.pages.history.catalog.cursor); try std.testing.expectEqual(@as(?usize, 1), app.pages.history.draft.anchor()); try std.testing.expectEqual(@as(usize, 0), app.pages.history.catalog.scroll); - try std.testing.expectEqual(@as(usize, 0), app.pages.history.diff.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), app.pages.history.diff.viewer.diff_scroll.row()); try app.update(.{ .terminal_resized = .{ .width = 120, .height = 32 } }, &ctx.ctx); try std.testing.expectEqual(chasen.Size{ .width = 120, .height = 32 }, app.terminal_size); @@ -1108,7 +1108,7 @@ test "History accepted diff runs one root interaction and transition sequence" { try std.testing.expectEqual(@as(usize, 2), app.pages.history.catalog.cursor); try std.testing.expectEqual(@as(?usize, 1), app.pages.history.draft.anchor()); try std.testing.expectEqual(@as(usize, 0), app.pages.history.catalog.scroll); - try std.testing.expectEqual(@as(usize, 0), app.pages.history.diff.viewer.diff_scroll); + try std.testing.expectEqual(@as(usize, 0), app.pages.history.diff.viewer.diff_scroll.row()); accepted_loaded.toggleHunkFold(0, 0); try app.update(.{ .terminal_resized = .{ .width = 120, .height = 12 } }, &ctx.ctx); layout = shellLayout(&app); @@ -1548,7 +1548,7 @@ test "mouse wheel scrolls the pane under the pointer" { const diff_msg = app.handleEvent(app_test_support.mouseEvent(diff_col, content.row + 2, .wheel_down)) orelse return error.ExpectedDiffWheelMessage; try app.update(diff_msg, undefined); try std.testing.expectEqual(changes_page.Focus.diff, app.pages.changes.viewer.focus); - try std.testing.expect(app.pages.changes.viewer.diff_scroll > 0); + try std.testing.expect(app.pages.changes.viewer.diff_scroll.row() > 0); } test "wheel redraw reaches root for meaningful sidebar transition complete noop and reverse" { @@ -1619,13 +1619,13 @@ test "wheel redraw reaches root for semantic vertical and horizontal edges" { vertical.pages.changes.viewer.diff_cursor = changesNavigation(&vertical).view().selectedCoordinateAtOffset(0) orelse return error.ExpectedCursorOffset; const vertical_owner = vertical.pages.changes.selection_owner; - const vertical_scroll_before = vertical.pages.changes.viewer.diff_scroll; + const vertical_scroll_before = vertical.pages.changes.viewer.diff_scroll.row(); const vertical_cursor_before = changesNavigation(&vertical).view().selectedDiffCursorOffset(); vertical_ctx.resetTransient(); try vertical.update(.{ .changes = .mouse_diff_wheel_down }, &vertical_ctx.ctx); try std.testing.expect(!vertical_ctx.redrawSuppressed()); try std.testing.expect( - vertical.pages.changes.viewer.diff_scroll != vertical_scroll_before or + vertical.pages.changes.viewer.diff_scroll.row() != vertical_scroll_before or changesNavigation(&vertical).view().selectedDiffCursorOffset() != vertical_cursor_before, ); try std.testing.expectEqual(changes_page.Focus.diff, vertical.pages.changes.viewer.focus); @@ -1644,14 +1644,14 @@ test "wheel redraw reaches root for semantic vertical and horizontal edges" { try std.testing.expect(reached_vertical_noop); const vertical_edge = changesNavigation(&vertical).view().selectedDiffCursorOffset() orelse return error.ExpectedCursorOffset; - const vertical_edge_scroll = vertical.pages.changes.viewer.diff_scroll; + const vertical_edge_scroll = vertical.pages.changes.viewer.diff_scroll.row(); try std.testing.expect(vertical_edge > 1); try std.testing.expectEqualDeep(vertical_owner, vertical.pages.changes.selection_owner); vertical_ctx.resetTransient(); try vertical.update(.{ .changes = .mouse_diff_wheel_up }, &vertical_ctx.ctx); try std.testing.expect(!vertical_ctx.redrawSuppressed()); try std.testing.expect( - vertical.pages.changes.viewer.diff_scroll != vertical_edge_scroll or + vertical.pages.changes.viewer.diff_scroll.row() != vertical_edge_scroll or changesNavigation(&vertical).view().selectedDiffCursorOffset().? != vertical_edge, ); try std.testing.expectEqualDeep(vertical_owner, vertical.pages.changes.selection_owner); @@ -1724,13 +1724,13 @@ test "Compare wheel redraw reaches root for meaningful complete noop and reverse } try std.testing.expect(reached_noop); const edge_cursor = body.selectedDiffCursorOffset(); - const edge_scroll = app.pages.compare.diff.viewer.diff_scroll; + const edge_scroll = app.pages.compare.diff.viewer.diff_scroll.row(); ctx.resetTransient(); try app.update(.{ .compare = .{ .common = .{ .shared = .mouse_diff_wheel_up } } }, &ctx.ctx); try std.testing.expect(!ctx.redrawSuppressed()); try std.testing.expect( - app.pages.compare.diff.viewer.diff_scroll != edge_scroll or + app.pages.compare.diff.viewer.diff_scroll.row() != edge_scroll or body.selectedDiffCursorOffset() != edge_cursor, ); } diff --git a/src/app/tests/update_tail.zig b/src/app/tests/update_tail.zig index a8eff99..e591dad 100644 --- a/src/app/tests/update_tail.zig +++ b/src/app/tests/update_tail.zig @@ -369,7 +369,7 @@ test "repo switch clears pending reload anchor" { .visible_sidebar_row = 0, .diff_cursor = .{ .metadata = 0 }, .diff_cursor_offset = 0, - .diff_scroll = 0, + .diff_scroll = .{ .logical = 0 }, .diff_horizontal_scroll = 0, .sidebar_horizontal_scroll = 0, .search_coordinate = null, @@ -1049,9 +1049,9 @@ test "Repository source wheel redraw preserves semantic and pointer owners" { .inert => return error.ExpectedRepositorySource, }; app.pages.repository.viewer.source_cursor = source.rowCount() - 1; - app.pages.repository.viewer.source_vertical_scroll = std.math.maxInt(usize); + app.pages.repository.viewer.source_vertical_scroll.line = std.math.maxInt(usize); app.pages.repository.clampForBodySize(body_size); - const bottom = app.pages.repository.viewer.source_vertical_scroll; + const bottom = app.pages.repository.viewer.source_vertical_scroll.line; try std.testing.expect(bottom > 0); const wheel_down: App.Msg = .{ .repository = .mouse_source_wheel_down }; @@ -1079,14 +1079,14 @@ test "Repository source wheel redraw preserves semantic and pointer owners" { const token = repositoryContentTokenForTest(&app.pages.repository); app.pages.repository.viewer.focus = .source; app.pages.repository.viewer.source_cursor = 3; - app.pages.repository.viewer.source_vertical_scroll = bottom - 1; + app.pages.repository.viewer.source_vertical_scroll.line = bottom - 1; app.pages.repository.selection_owner = .{ .source = repository_selection.DragSelection.initKeyboardLine(token, 3) }; const endpoint_before = app.pages.repository.selection_owner.activeKeyboardLineSelection().?.focus; ctx.resetTransient(); try app.update(wheel_down, &ctx.ctx); try std.testing.expect(!ctx.redrawSuppressed()); - try std.testing.expectEqual(bottom, app.pages.repository.viewer.source_vertical_scroll); + try std.testing.expectEqual(bottom, app.pages.repository.viewer.source_vertical_scroll.line); try std.testing.expectEqual(@as(usize, 3), app.pages.repository.viewer.source_cursor); try std.testing.expect(std.meta.eql(endpoint_before, app.pages.repository.selection_owner.activeKeyboardLineSelection().?.focus)); try std.testing.expect(app.pages.repository.selection_owner.activeKeyboardLineSelection().?.token.eql(token)); @@ -1241,7 +1241,7 @@ test "changes repository transition unavailable path is not replayed after reloa .selected_target = .{ .diff_file = 0 }, .selected_node = 0, .diff_cursor = .{ .metadata = 0 }, - .diff_scroll = 5, + .diff_scroll = .{ .logical = 5 }, }, }, .repository = .{ diff --git a/src/app/view.zig b/src/app/view.zig index b42477a..cc6fd31 100644 --- a/src/app/view.zig +++ b/src/app/view.zig @@ -42,7 +42,7 @@ const content_fingerprint = if (builtin.is_test) @import("../content_fingerprint /// shared with tests through small public helpers. const shell_frame_border = ui.Panel.Border.rounded; const help_dialog_max_width: u16 = 120; -const help_dialog_max_height: u16 = 38; +const help_dialog_max_height: u16 = 39; const help_two_column_min_width: u16 = 96; const help_column_gap: u16 = 2; const help_footer_rows: u16 = 1; @@ -3268,6 +3268,26 @@ test "help preserves configured keys and aligned wrapped descriptions while scro } } +test "source Help displays the effective line wrap key" { + var harness: ShellViewTestHarness = .{}; + for ([_][]const u8{ "W", "x" }) |binding| { + var config: keymap.Config = .{}; + if (std.mem.eql(u8, binding, "x")) config.set(.toggle_line_wrap, keymap.parseKeySpec(binding).?); + harness.keymap = .fromConfig(config); + const context = harness.context(); + for ([_]HelpItem{ help_diff_items[6], help_repository_source_items[5] }) |item| { + var surface: chasen.testing.TestSurface = undefined; + try surface.init(60, 1); + defer surface.deinit(); + _ = try drawHelpItem(helpContext(context), context.theme, &surface.surface, 0, 0, item); + try surface.expectCellText(0, 0, binding); + const snapshot = try surface.snapshot(std.testing.allocator); + defer std.testing.allocator.free(snapshot); + try std.testing.expect(std.mem.indexOf(u8, snapshot, "toggle line wrap") != null); + } + } +} + test "diff Help copy keys and vocabulary follow the keymap and effective mode" { var harness: ShellViewTestHarness = .{ .terminal_size = .{ .width = 160, .height = 40 } }; var config: keymap.Config = .{}; @@ -3961,6 +3981,7 @@ const help_repository_source_items = [_]HelpItem{ .{ .key = .{ .text = "Y" }, .description = "copy selected code with context" }, .{ .key = .{ .action = .search }, .description = "search source" }, .{ .key = .{ .action = .toggle_line_numbers }, .description = "toggle line numbers" }, + .{ .key = .{ .action = .toggle_line_wrap }, .description = "toggle line wrap" }, help_group_separator, .{ .kind = .navigation, .key = .{ .pair = .{ .left = .previous_file, .right = .next_file } }, .description = "previous / next file (source)" }, .{ .kind = .navigation, .key = .{ .text = "↑/↓ j/k" }, .description = "move one source row" }, @@ -3999,6 +4020,7 @@ const help_diff_items = [_]HelpItem{ .{ .key = .{ .action = .search }, .description = "search diff" }, .{ .key = .{ .action = .toggle_display_mode }, .description = "unified / side-by-side" }, .{ .key = .{ .action = .toggle_line_numbers }, .description = "toggle line numbers" }, + .{ .key = .{ .action = .toggle_line_wrap }, .description = "toggle line wrap" }, .{ .key = .{ .text = "Enter" }, .description = "fold / unfold hunk" }, .{ .key = .{ .text = "y / Esc" }, .description = "copy / clear selection" }, help_group_separator, diff --git a/src/app/workflow/local_test.zig b/src/app/workflow/local_test.zig index 69898b4..e1bd0b8 100644 --- a/src/app/workflow/local_test.zig +++ b/src/app/workflow/local_test.zig @@ -866,7 +866,7 @@ test "selectedHunkUnstageTarget requires a visible session-staged hunk" { else => return error.ExpectedReadyHunkUnstageTarget, } - app.pages.changes.viewer.diff_scroll = 100; + app.pages.changes.viewer.diff_scroll = .{ .logical = 100 }; switch (app.changesOperations().selectedHunkUnstageTarget(std.testing.allocator)) { .offscreen_cursor => {}, else => return error.ExpectedOffscreenHunkUnstageTarget, diff --git a/src/diff/render.zig b/src/diff/render.zig index 153cd18..58df5a6 100644 --- a/src/diff/render.zig +++ b/src/diff/render.zig @@ -15,6 +15,7 @@ const syntax_style = @import("../syntax/style.zig"); const syntax_token = @import("../syntax/token.zig"); const theme = @import("theme"); const text_projection = @import("chasen_ui").text_projection; +const line_wrap = @import("../line_wrap.zig"); pub const DisplayMode = diff_view_model.DisplayMode; @@ -48,9 +49,12 @@ pub const RenderOptions = struct { requested_mode: DisplayMode = .unified, display_mode_toggle_key: ?[]const u8 = null, scroll: usize = 0, + /// Derived first fragment of the first logical row; never a source coordinate. + wrap_start: usize = 0, horizontal_scroll: usize = 0, pane_active: bool = true, line_numbers: bool = true, + line_wrap: bool = false, highlighted_hunk: ?usize = null, cursor_offset: ?usize = null, hunk_stages: HunkStagePresentation = .all_unstaged, @@ -66,6 +70,93 @@ pub const RenderOptions = struct { inline_row_painter: ?InlineRowPainter = null, }; +const TextWindow = union(enum) { + scroll: usize, + wrap: ?line_wrap.Fragment, + + fn continuation(self: TextWindow) bool { + return switch (self) { + .scroll => false, + .wrap => |fragment| if (fragment) |value| value.continuation else true, + }; + } + + fn segments(self: TextWindow, projection: text_projection.Projection, width: u16) line_wrap.Segments { + return switch (self) { + .scroll => |cells| .{ .projected = projection.visibleSegments(cells, width) }, + .wrap => |fragment| if (fragment) |value| value.segments() else .{ .replacement = null }, + }; + } +}; + +const SideWindows = struct { old: TextWindow, new: TextWindow }; +const RowWindows = struct { unified: TextWindow, sides: SideWindows }; + +/// One sequential token iterator per admitted side; no per-fragment prefix scan. +const RowWrap = struct { + unified: ?line_wrap.Iterator = null, + old: ?line_wrap.Iterator = null, + new: ?line_wrap.Iterator = null, + + fn init(projections: BodyRowProjections, body_width: u16, line_numbers: bool) RowWrap { + return switch (projections) { + .none => .{}, + .unified => |text| .{ .unified = line_wrap.Iterator.init(text, body_width -| lineTextStart(line_numbers, .unified)) }, + .side_by_side => |sides| blk: { + const geometry = sideBySideGeometry(body_width); + const text_col = lineTextStart(line_numbers, .side_by_side); + break :blk .{ + .old = if (sides.old) |text| line_wrap.Iterator.init(text, geometry.old.width -| text_col) else null, + .new = if (sides.new) |text| line_wrap.Iterator.init(text, geometry.new.width -| text_col) else null, + }; + }, + }; + } + + fn height(self: RowWrap) usize { + var rows: usize = 1; + if (self.unified) |iterator| rows = @max(rows, iterator.height()); + if (self.old) |iterator| rows = @max(rows, iterator.height()); + if (self.new) |iterator| rows = @max(rows, iterator.height()); + return rows; + } + + fn next(self: *RowWrap, options: RenderOptions) RowWindows { + if (!options.line_wrap) { + const window: TextWindow = .{ .scroll = options.horizontal_scroll }; + return .{ .unified = window, .sides = .{ .old = window, .new = window } }; + } + return .{ + .unified = .{ .wrap = if (self.unified) |*iterator| iterator.next() else null }, + .sides = .{ + .old = .{ .wrap = if (self.old) |*iterator| iterator.next() else null }, + .new = .{ .wrap = if (self.new) |*iterator| iterator.next() else null }, + }, + }; + } +}; + +/// Navigation and painting use the same fragment boundaries and paired height. +pub fn wrappedRowHeight(body_row: diff_view_model.BodyRow, body_width: u16, line_numbers: bool) usize { + const projections = admitBodyRow(body_row) catch return 1; + return RowWrap.init(projections, body_width, line_numbers).height(); +} + +pub fn generatedBodyRow(line: diff_parser.DiffLine, mode: DisplayMode) diff_view_model.BodyRow { + return switch (mode) { + .unified => .{ + .unified_line = line, + }, + .side_by_side => .{ + .side_by_side = .{ + .paired = .{ + .added = line, + }, + }, + }, + }; +} + pub const InlineBlockKind = union(enum) { card: usize, spacer, @@ -1062,41 +1153,67 @@ pub fn renderFile(surface: *chasen.Surface, file: diff_parser.FileDiff, options: current_hunk_highlighted = false; } const body_offset = cursor.bodyOffset(); - const row = cursor.nextRow() orelse continue; + const first_row = cursor.nextRow() orelse continue; const presentation = RowPresentation.forBodyOffset(options, body_offset); const projections = try admitBodyRow(body_row); - presentation.prefill(surface, row, styles); - drawCursorMarker(surface, row, body_offset, options.cursor_offset, presentation, styles); - switch (body_row) { - .metadata => |line| try draw.copyClippedTextAt(&body_surface, 0, row, line, presentation.compose(styles.metadata, styles)), - .binary_marker => _ = body_surface.borrowTextAt(0, row, "Binary file", presentation.compose(styles.warning, styles)), - .hunk_header => |hunk| { - const current_stage = if (current_hunk_highlighted) options.hunk_stages.stateForHunk(hunk.hunk_index) else null; - if (current_stage != null and !hunk.folded) drawHunkGuide(surface, row, guideGlyph(guide_index, hunk.hunk_index, body_offset), current_stage.?, presentation, styles); - try drawHunkHeaderRow(&body_surface, row, hunk, current_stage, mode, presentation, styles); - if (current_stage != null and !hunk.folded and mode == .side_by_side) drawSideBySideHunkGuide(&body_surface, row, separatorGuideGlyph(guide_index, hunk.hunk_index, body_offset), current_stage.?, presentation, styles); - }, - .unified_line => |line| { - if (current_hunk_highlighted) { - if (rows.currentHunkIndex()) |hunk_index| drawHunkGuide(surface, row, guideGlyph(guide_index, hunk_index, body_offset), options.hunk_stages.stateForHunk(hunk_index), presentation, styles); - } - const syntax_ctx = unifiedSyntaxContext(options, rows, line); - try drawUnifiedLine(&body_surface, row, line, projections.unified, options.horizontal_scroll, options.line_numbers, presentation, styles, syntax_ctx.line_spans, syntax_ctx.hunk_side_has_visible_syntax, unifiedSelectionForLine(options, rows, line)); - }, - .side_by_side => |side_row| { - if (current_hunk_highlighted) { - if (rows.currentHunkIndex()) |hunk_index| drawHunkGuide(surface, row, guideGlyph(guide_index, hunk_index, body_offset), options.hunk_stages.stateForHunk(hunk_index), presentation, styles); - } - const geometry = sideBySideGeometry(body_surface.size().width); - const indexed_row = rows.currentSideBySideRow(); - switch (side_row) { - .single => |line| try drawSideBySideSingle(&body_surface, row, line, projections.side_by_side, geometry, options.horizontal_scroll, options.line_numbers, presentation, styles, sideBySideSingleSyntaxSpans(options, rows, line), sideBySideSelectionForIndexedRow(options, file, rows.currentHunkIndex(), indexed_row)), - .paired => |pair| try drawSideBySidePair(&body_surface, row, pair.removed, pair.added, projections.side_by_side, geometry, options.horizontal_scroll, options.line_numbers, presentation, styles, sideBySidePairSyntaxSpans(options, rows), sideBySideSelectionForIndexedRow(options, file, rows.currentHunkIndex(), indexed_row)), - } - if (current_hunk_highlighted) { - if (rows.currentHunkIndex()) |hunk_index| drawSideBySideHunkGuide(&body_surface, row, separatorGuideGlyph(guide_index, hunk_index, body_offset), options.hunk_stages.stateForHunk(hunk_index), presentation, styles); - } - }, + var wrapping = RowWrap.init(projections, body_surface.size().width, options.line_numbers); + const row_height = if (options.line_wrap) wrapping.height() else 1; + const wrap_start = if (options.line_wrap and body_offset == options.scroll) @min(options.wrap_start, row_height - 1) else 0; + for (0..wrap_start) |_| _ = wrapping.next(options); + var wrap_row: usize = wrap_start; + + while (wrap_row < row_height) : (wrap_row += 1) { + const row = if (wrap_row == wrap_start) first_row else cursor.continuationRow() orelse break; + const windows = wrapping.next(options); + const wrap_tail = wrap_row + 1 == row_height; + presentation.prefill(surface, row, styles); + if (wrap_row == wrap_start) drawCursorMarker(surface, row, body_offset, options.cursor_offset, presentation, styles); + + switch (body_row) { + .metadata => |line| { + try draw.copyClippedTextAt(&body_surface, 0, row, line, presentation.compose(styles.metadata, styles)); + }, + .binary_marker => { + _ = body_surface.borrowTextAt(0, row, "Binary file", presentation.compose(styles.warning, styles)); + }, + .hunk_header => |hunk| { + const current_stage = if (current_hunk_highlighted) options.hunk_stages.stateForHunk(hunk.hunk_index) else null; + + if (current_stage != null and !hunk.folded) { + drawHunkGuide(surface, row, guideGlyph(guide_index, hunk.hunk_index, body_offset), current_stage.?, presentation, styles); + } + + try drawHunkHeaderRow(&body_surface, row, hunk, current_stage, mode, presentation, styles); + + if (current_stage != null and !hunk.folded and mode == .side_by_side) { + drawSideBySideHunkGuide(&body_surface, row, separatorGuideGlyph(guide_index, hunk.hunk_index, body_offset), current_stage.?, presentation, styles); + } + }, + .unified_line => |line| { + if (current_hunk_highlighted) { + if (rows.currentHunkIndex()) |hunk_index| drawHunkGuide(surface, row, if (wrap_tail) guideGlyph(guide_index, hunk_index, body_offset) else "┃", options.hunk_stages.stateForHunk(hunk_index), presentation, styles); + } + const syntax_ctx = unifiedSyntaxContext(options, rows, line); + try drawUnifiedLine(&body_surface, row, line, projections.unified, windows.unified, options.line_numbers, presentation, styles, syntax_ctx.line_spans, syntax_ctx.hunk_side_has_visible_syntax, unifiedSelectionForLine(options, rows, line)); + }, + .side_by_side => |side_row| { + if (current_hunk_highlighted) { + if (rows.currentHunkIndex()) |hunk_index| drawHunkGuide(surface, row, if (wrap_tail) guideGlyph(guide_index, hunk_index, body_offset) else "┃", options.hunk_stages.stateForHunk(hunk_index), presentation, styles); + } + + const geometry = sideBySideGeometry(body_surface.size().width); + const indexed_row = rows.currentSideBySideRow(); + + switch (side_row) { + .single => |line| try drawSideBySideSingle(&body_surface, row, line, projections.side_by_side, geometry, windows.sides, options.line_numbers, presentation, styles, sideBySideSingleSyntaxSpans(options, rows, line), sideBySideSelectionForIndexedRow(options, file, rows.currentHunkIndex(), indexed_row)), + .paired => |pair| try drawSideBySidePair(&body_surface, row, pair.removed, pair.added, projections.side_by_side, geometry, windows.sides, options.line_numbers, presentation, styles, sideBySidePairSyntaxSpans(options, rows), sideBySideSelectionForIndexedRow(options, file, rows.currentHunkIndex(), indexed_row)), + } + + if (current_hunk_highlighted) { + if (rows.currentHunkIndex()) |hunk_index| drawSideBySideHunkGuide(&body_surface, row, if (wrap_tail) separatorGuideGlyph(guide_index, hunk_index, body_offset) else "┃", options.hunk_stages.stateForHunk(hunk_index), presentation, styles); + } + }, + } } } } @@ -1131,6 +1248,7 @@ fn renderFileWithPresentation( while (skipped < source_start) : (skipped += 1) _ = rows.next() orelse break; } + const window: TextWindow = .{ .scroll = options.horizontal_scroll }; var presentation_offset = options.scroll; var screen_row: u16 = body_start_row; var current_hunk_highlighted = false; @@ -1192,7 +1310,7 @@ fn renderFileWithPresentation( if (rows.currentHunkIndex()) |hunk_index| drawHunkGuide(surface, screen_row, guideGlyph(guide_index, hunk_index, source_offset), options.hunk_stages.stateForHunk(hunk_index), row_presentation, styles); } const syntax_ctx = unifiedSyntaxContext(options, rows, line); - try drawUnifiedLine(body_surface, screen_row, line, projections.unified, options.horizontal_scroll, options.line_numbers, row_presentation, styles, syntax_ctx.line_spans, syntax_ctx.hunk_side_has_visible_syntax, unifiedSelectionForLine(options, rows, line)); + try drawUnifiedLine(body_surface, screen_row, line, projections.unified, window, options.line_numbers, row_presentation, styles, syntax_ctx.line_spans, syntax_ctx.hunk_side_has_visible_syntax, unifiedSelectionForLine(options, rows, line)); }, .side_by_side => |side_row| { if (current_hunk_highlighted) { @@ -1201,8 +1319,8 @@ fn renderFileWithPresentation( const geometry = sideBySideGeometry(body_surface.size().width); const indexed_row = rows.currentSideBySideRow(); switch (side_row) { - .single => |line| try drawSideBySideSingle(body_surface, screen_row, line, projections.side_by_side, geometry, options.horizontal_scroll, options.line_numbers, row_presentation, styles, sideBySideSingleSyntaxSpans(options, rows, line), sideBySideSelectionForIndexedRow(options, file, rows.currentHunkIndex(), indexed_row)), - .paired => |pair| try drawSideBySidePair(body_surface, screen_row, pair.removed, pair.added, projections.side_by_side, geometry, options.horizontal_scroll, options.line_numbers, row_presentation, styles, sideBySidePairSyntaxSpans(options, rows), sideBySideSelectionForIndexedRow(options, file, rows.currentHunkIndex(), indexed_row)), + .single => |line| try drawSideBySideSingle(body_surface, screen_row, line, projections.side_by_side, geometry, .{ .old = window, .new = window }, options.line_numbers, row_presentation, styles, sideBySideSingleSyntaxSpans(options, rows, line), sideBySideSelectionForIndexedRow(options, file, rows.currentHunkIndex(), indexed_row)), + .paired => |pair| try drawSideBySidePair(body_surface, screen_row, pair.removed, pair.added, projections.side_by_side, geometry, .{ .old = window, .new = window }, options.line_numbers, row_presentation, styles, sideBySidePairSyntaxSpans(options, rows), sideBySideSelectionForIndexedRow(options, file, rows.currentHunkIndex(), indexed_row)), } if (current_hunk_highlighted) { if (rows.currentHunkIndex()) |hunk_index| drawSideBySideHunkGuide(body_surface, screen_row, separatorGuideGlyph(guide_index, hunk_index, source_offset), options.hunk_stages.stateForHunk(hunk_index), row_presentation, styles); @@ -1299,7 +1417,7 @@ pub fn renderGeneratedAddedFile(surface: *chasen.Surface, path: []const u8, sour for (0..source.rowCount()) |index| { if (cursor.done()) return; const body_offset = cursor.bodyOffset(); - const row = cursor.nextRow() orelse continue; + const first_row = cursor.nextRow() orelse continue; const line: diff_parser.DiffLine = .{ .kind = .added, .text = source.lineBody(index).?, @@ -1307,17 +1425,27 @@ pub fn renderGeneratedAddedFile(surface: *chasen.Surface, path: []const u8, sour }; const projection = try text_projection.Projection.init(line.text, .{ .tab_width = review_tab_width }); const presentation = RowPresentation.forBodyOffset(options, body_offset); - presentation.prefill(surface, row, styles); - drawCursorMarker(surface, row, body_offset, options.cursor_offset, presentation, styles); - const line_spans = options.source_syntax_spans.lineSpans(index); - if (mode == .side_by_side) { - const geometry = sideBySideGeometry(body_surface.size().width); - try drawSideBySidePair(&body_surface, row, null, line, .{ .new = projection }, geometry, options.horizontal_scroll, options.line_numbers, presentation, styles, .{ - .new = line_spans, - .new_hunk_side_has_visible_syntax = options.source_has_visible_syntax, - }, generatedSideBySideSelection(options, index, line)); - } else { - try drawUnifiedLine(&body_surface, row, line, projection, options.horizontal_scroll, options.line_numbers, presentation, styles, line_spans, options.source_has_visible_syntax, generatedUnifiedSelection(options, index, line)); + const projections: BodyRowProjections = if (mode == .unified) .{ .unified = projection } else .{ .side_by_side = .{ .new = projection } }; + var wrapping = RowWrap.init(projections, body_surface.size().width, options.line_numbers); + const row_height = if (options.line_wrap) wrapping.height() else 1; + const wrap_start = if (options.line_wrap and body_offset == options.scroll) @min(options.wrap_start, row_height - 1) else 0; + for (0..wrap_start) |_| _ = wrapping.next(options); + var wrap_row: usize = wrap_start; + while (wrap_row < row_height) : (wrap_row += 1) { + const row = if (wrap_row == wrap_start) first_row else cursor.continuationRow() orelse break; + const windows = wrapping.next(options); + presentation.prefill(surface, row, styles); + if (wrap_row == wrap_start) drawCursorMarker(surface, row, body_offset, options.cursor_offset, presentation, styles); + const line_spans = options.source_syntax_spans.lineSpans(index); + if (mode == .side_by_side) { + const geometry = sideBySideGeometry(body_surface.size().width); + try drawSideBySidePair(&body_surface, row, null, line, .{ .new = projection }, geometry, windows.sides, options.line_numbers, presentation, styles, .{ + .new = line_spans, + .new_hunk_side_has_visible_syntax = options.source_has_visible_syntax, + }, generatedSideBySideSelection(options, index, line)); + } else { + try drawUnifiedLine(&body_surface, row, line, projection, windows.unified, options.line_numbers, presentation, styles, line_spans, options.source_has_visible_syntax, generatedUnifiedSelection(options, index, line)); + } } } } @@ -1671,6 +1799,13 @@ const BodyCursor = struct { return row; } + /// Take one more screen row for the body row `nextRow` just returned. + fn continuationRow(self: *BodyCursor) ?u16 { + if (self.row >= self.height) return null; + defer self.row += 1; + return self.row; + } + fn done(self: BodyCursor) bool { return self.virtual_row >= self.scroll and self.row >= self.height; } @@ -1724,7 +1859,7 @@ fn admitSideBySideRow(row: diff_view_model.SideBySideRow) text_projection.Error! }; } -fn drawUnifiedLine(surface: *chasen.Surface, row: u16, line: diff_parser.DiffLine, projection: text_projection.Projection, horizontal_scroll: usize, line_numbers: bool, presentation: RowPresentation, styles: RenderStyles, syntax_spans: syntax_token.LineSpans, hunk_side_has_visible_syntax: bool, selection: ?diff_selection.LineVisualRange) !void { +fn drawUnifiedLine(surface: *chasen.Surface, row: u16, line: diff_parser.DiffLine, projection: text_projection.Projection, window: TextWindow, line_numbers: bool, presentation: RowPresentation, styles: RenderStyles, syntax_spans: syntax_token.LineSpans, hunk_side_has_visible_syntax: bool, selection: ?diff_selection.LineVisualRange) !void { prefillIntrinsicDiffBackground(surface, row, .{ .col = 0, .width = surface.size().width }, line.kind, presentation, styles); const whole_line = selection != null and selection.?.mode == .line; if (whole_line) fillRowRegion(surface, row, .{ .col = 0, .width = surface.size().width }, styles.selection); @@ -1736,12 +1871,12 @@ fn drawUnifiedLine(surface: *chasen.Surface, row: u16, line: diff_parser.DiffLin if (line_numbers) { fillRowRegion(surface, row, .{ .col = 0, .width = layout.prefix_col }, selectedStyle(presentation.compose(lineNumberStyle(line.kind, styles), styles), whole_line, styles)); - _ = try surface.copyTextAt(0, row, try lineNumberText(surface, line.old_line), selectedStyle(presentation.lineNumber(lineNumberStyle(line.kind, styles), line.old_line != null, styles), whole_line, styles)); - _ = try surface.copyTextAt(5, row, try lineNumberText(surface, line.new_line), selectedStyle(presentation.lineNumber(lineNumberStyle(line.kind, styles), line.new_line != null, styles), whole_line, styles)); + if (!window.continuation()) _ = try surface.copyTextAt(0, row, try lineNumberText(surface, line.old_line), selectedStyle(presentation.lineNumber(lineNumberStyle(line.kind, styles), line.old_line != null, styles), whole_line, styles)); + if (!window.continuation()) _ = try surface.copyTextAt(5, row, try lineNumberText(surface, line.new_line), selectedStyle(presentation.lineNumber(lineNumberStyle(line.kind, styles), line.new_line != null, styles), whole_line, styles)); } - _ = surface.borrowTextAt(layout.prefix_col, row, prefix, marker_style); - copyStyledScrolledTextAt(surface, layout.text_col, row, projection, horizontal_scroll, text_style, syntax_spans, styles); - if (selection) |selected| if (selected.mode == .character) applyCharacterSelection(surface, layout.text_col, row, projection, horizontal_scroll, selected, styles.selection.bg); + if (!window.continuation()) _ = surface.borrowTextAt(layout.prefix_col, row, prefix, marker_style); + copyStyledScrolledTextAt(surface, layout.text_col, row, projection, window, text_style, syntax_spans, styles); + if (selection) |selected| if (selected.mode == .character) applyCharacterSelection(surface, layout.text_col, row, projection, window, selected, styles.selection.bg); } const SideBySideSyntaxSpans = struct { @@ -1774,18 +1909,18 @@ fn generatedSideBySideSelection(options: RenderOptions, line_index: usize, line: return .{ .new = range }; } -fn drawSideBySidePair(surface: *chasen.Surface, row: u16, removed: ?diff_parser.DiffLine, added: ?diff_parser.DiffLine, projections: SideBySideProjections, geometry: SideBySideGeometry, horizontal_scroll: usize, line_numbers: bool, presentation: RowPresentation, styles: RenderStyles, syntax_spans: SideBySideSyntaxSpans, selection: ?SideBySideSelection) !void { +fn drawSideBySidePair(surface: *chasen.Surface, row: u16, removed: ?diff_parser.DiffLine, added: ?diff_parser.DiffLine, projections: SideBySideProjections, geometry: SideBySideGeometry, windows: SideWindows, line_numbers: bool, presentation: RowPresentation, styles: RenderStyles, syntax_spans: SideBySideSyntaxSpans, selection: ?SideBySideSelection) !void { if (removed) |line| prefillIntrinsicDiffBackground(surface, row, geometry.old, line.kind, presentation, styles); if (added) |line| prefillIntrinsicDiffBackground(surface, row, geometry.new, line.kind, presentation, styles); drawSideBySideSelection(surface, row, geometry, selection, styles); var columns = sideBySideRowColumns(surface, row, geometry); const selected = selection orelse SideBySideSelection{}; - if (removed) |line| try drawSideBySideOld(&columns.old, 0, line, projections.old.?, horizontal_scroll, line_numbers, presentation, styles, syntax_spans.old, syntax_spans.old_hunk_side_has_visible_syntax, selected.old); - if (added) |line| try drawSideBySideNew(&columns.new, 0, line, projections.new.?, horizontal_scroll, line_numbers, presentation, styles, syntax_spans.new, syntax_spans.new_hunk_side_has_visible_syntax, selected.new); + if (removed) |line| try drawSideBySideOld(&columns.old, 0, line, projections.old.?, windows.old, line_numbers, presentation, styles, syntax_spans.old, syntax_spans.old_hunk_side_has_visible_syntax, selected.old); + if (added) |line| try drawSideBySideNew(&columns.new, 0, line, projections.new.?, windows.new, line_numbers, presentation, styles, syntax_spans.new, syntax_spans.new_hunk_side_has_visible_syntax, selected.new); drawSideBySideGutter(surface, row, geometry, presentation, styles); } -fn drawSideBySideSingle(surface: *chasen.Surface, row: u16, line: diff_parser.DiffLine, projections: SideBySideProjections, geometry: SideBySideGeometry, horizontal_scroll: usize, line_numbers: bool, presentation: RowPresentation, styles: RenderStyles, syntax_spans: SideBySideSyntaxSpans, selection: ?SideBySideSelection) !void { +fn drawSideBySideSingle(surface: *chasen.Surface, row: u16, line: diff_parser.DiffLine, projections: SideBySideProjections, geometry: SideBySideGeometry, windows: SideWindows, line_numbers: bool, presentation: RowPresentation, styles: RenderStyles, syntax_spans: SideBySideSyntaxSpans, selection: ?SideBySideSelection) !void { switch (line.kind) { .removed => prefillIntrinsicDiffBackground(surface, row, geometry.old, line.kind, presentation, styles), .added => prefillIntrinsicDiffBackground(surface, row, geometry.new, line.kind, presentation, styles), @@ -1796,16 +1931,16 @@ fn drawSideBySideSingle(surface: *chasen.Surface, row: u16, line: diff_parser.Di const selected = selection orelse SideBySideSelection{}; switch (line.kind) { .removed => { - try drawSideBySideOld(&columns.old, 0, line, projections.old.?, horizontal_scroll, line_numbers, presentation, styles, syntax_spans.old, syntax_spans.old_hunk_side_has_visible_syntax, selected.old); + try drawSideBySideOld(&columns.old, 0, line, projections.old.?, windows.old, line_numbers, presentation, styles, syntax_spans.old, syntax_spans.old_hunk_side_has_visible_syntax, selected.old); drawSideBySideGutter(surface, row, geometry, presentation, styles); }, .added => { - try drawSideBySideNew(&columns.new, 0, line, projections.new.?, horizontal_scroll, line_numbers, presentation, styles, syntax_spans.new, syntax_spans.new_hunk_side_has_visible_syntax, selected.new); + try drawSideBySideNew(&columns.new, 0, line, projections.new.?, windows.new, line_numbers, presentation, styles, syntax_spans.new, syntax_spans.new_hunk_side_has_visible_syntax, selected.new); drawSideBySideGutter(surface, row, geometry, presentation, styles); }, .context => { - try drawSideBySideOld(&columns.old, 0, line, projections.old.?, horizontal_scroll, line_numbers, presentation, styles, syntax_spans.old, syntax_spans.old_hunk_side_has_visible_syntax, selected.old); - try drawSideBySideNew(&columns.new, 0, line, projections.new.?, horizontal_scroll, line_numbers, presentation, styles, syntax_spans.new, syntax_spans.new_hunk_side_has_visible_syntax, selected.new); + try drawSideBySideOld(&columns.old, 0, line, projections.old.?, windows.old, line_numbers, presentation, styles, syntax_spans.old, syntax_spans.old_hunk_side_has_visible_syntax, selected.old); + try drawSideBySideNew(&columns.new, 0, line, projections.new.?, windows.new, line_numbers, presentation, styles, syntax_spans.new, syntax_spans.new_hunk_side_has_visible_syntax, selected.new); drawSideBySideGutter(surface, row, geometry, presentation, styles); }, .metadata => { @@ -1897,32 +2032,32 @@ fn sideBySideSelectionForPair(options: RenderOptions, file: diff_parser.FileDiff return selected; } -fn drawSideBySideOld(surface: *chasen.Surface, row: u16, line: diff_parser.DiffLine, projection: text_projection.Projection, horizontal_scroll: usize, line_numbers: bool, presentation: RowPresentation, styles: RenderStyles, syntax_spans: syntax_token.LineSpans, hunk_side_has_visible_syntax: bool, selection: ?diff_selection.LineVisualRange) !void { +fn drawSideBySideOld(surface: *chasen.Surface, row: u16, line: diff_parser.DiffLine, projection: text_projection.Projection, window: TextWindow, line_numbers: bool, presentation: RowPresentation, styles: RenderStyles, syntax_spans: syntax_token.LineSpans, hunk_side_has_visible_syntax: bool, selection: ?diff_selection.LineVisualRange) !void { const selected = selection != null and selection.?.mode == .line; const layout = lineLayout(line_numbers, .side_by_side); drawGutterLeadInBackground(surface, row, layout, selectedStyle(presentation.compose(gutterLeadInStyle(line.kind, styles), styles), selected, styles)); if (line_numbers) { fillRowRegion(surface, row, .{ .col = 0, .width = layout.prefix_col }, selectedStyle(presentation.compose(lineNumberStyle(line.kind, styles), styles), selected, styles)); - _ = try surface.copyTextAt(0, row, try lineNumberText(surface, line.old_line), selectedStyle(presentation.lineNumber(lineNumberStyle(line.kind, styles), line.old_line != null, styles), selected, styles)); + if (!window.continuation()) _ = try surface.copyTextAt(0, row, try lineNumberText(surface, line.old_line), selectedStyle(presentation.lineNumber(lineNumberStyle(line.kind, styles), line.old_line != null, styles), selected, styles)); } const prefix = presentation.prefix(line.kind, hunk_side_has_visible_syntax); - _ = surface.borrowTextAt(layout.prefix_col, row, prefix, selectedStyle(presentation.compose(markerStyleForLine(line.kind, styles), styles), selected, styles)); - copyStyledScrolledTextAt(surface, layout.text_col, row, projection, horizontal_scroll, selectedStyle(presentation.compose(bodyTextStyleForLine(line.kind, styles, hunk_side_has_visible_syntax), styles), selected, styles), syntax_spans, styles); - if (selection) |range| if (range.mode == .character) applyCharacterSelection(surface, layout.text_col, row, projection, horizontal_scroll, range, styles.selection.bg); + if (!window.continuation()) _ = surface.borrowTextAt(layout.prefix_col, row, prefix, selectedStyle(presentation.compose(markerStyleForLine(line.kind, styles), styles), selected, styles)); + copyStyledScrolledTextAt(surface, layout.text_col, row, projection, window, selectedStyle(presentation.compose(bodyTextStyleForLine(line.kind, styles, hunk_side_has_visible_syntax), styles), selected, styles), syntax_spans, styles); + if (selection) |range| if (range.mode == .character) applyCharacterSelection(surface, layout.text_col, row, projection, window, range, styles.selection.bg); } -fn drawSideBySideNew(surface: *chasen.Surface, row: u16, line: diff_parser.DiffLine, projection: text_projection.Projection, horizontal_scroll: usize, line_numbers: bool, presentation: RowPresentation, styles: RenderStyles, syntax_spans: syntax_token.LineSpans, hunk_side_has_visible_syntax: bool, selection: ?diff_selection.LineVisualRange) !void { +fn drawSideBySideNew(surface: *chasen.Surface, row: u16, line: diff_parser.DiffLine, projection: text_projection.Projection, window: TextWindow, line_numbers: bool, presentation: RowPresentation, styles: RenderStyles, syntax_spans: syntax_token.LineSpans, hunk_side_has_visible_syntax: bool, selection: ?diff_selection.LineVisualRange) !void { const selected = selection != null and selection.?.mode == .line; const layout = lineLayout(line_numbers, .side_by_side); drawGutterLeadInBackground(surface, row, layout, selectedStyle(presentation.compose(gutterLeadInStyle(line.kind, styles), styles), selected, styles)); if (line_numbers) { fillRowRegion(surface, row, .{ .col = 0, .width = layout.prefix_col }, selectedStyle(presentation.compose(lineNumberStyle(line.kind, styles), styles), selected, styles)); - _ = try surface.copyTextAt(0, row, try lineNumberText(surface, line.new_line), selectedStyle(presentation.lineNumber(lineNumberStyle(line.kind, styles), line.new_line != null, styles), selected, styles)); + if (!window.continuation()) _ = try surface.copyTextAt(0, row, try lineNumberText(surface, line.new_line), selectedStyle(presentation.lineNumber(lineNumberStyle(line.kind, styles), line.new_line != null, styles), selected, styles)); } const prefix = presentation.prefix(line.kind, hunk_side_has_visible_syntax); - _ = surface.borrowTextAt(layout.prefix_col, row, prefix, selectedStyle(presentation.compose(markerStyleForLine(line.kind, styles), styles), selected, styles)); - copyStyledScrolledTextAt(surface, layout.text_col, row, projection, horizontal_scroll, selectedStyle(presentation.compose(bodyTextStyleForLine(line.kind, styles, hunk_side_has_visible_syntax), styles), selected, styles), syntax_spans, styles); - if (selection) |range| if (range.mode == .character) applyCharacterSelection(surface, layout.text_col, row, projection, horizontal_scroll, range, styles.selection.bg); + if (!window.continuation()) _ = surface.borrowTextAt(layout.prefix_col, row, prefix, selectedStyle(presentation.compose(markerStyleForLine(line.kind, styles), styles), selected, styles)); + copyStyledScrolledTextAt(surface, layout.text_col, row, projection, window, selectedStyle(presentation.compose(bodyTextStyleForLine(line.kind, styles, hunk_side_has_visible_syntax), styles), selected, styles), syntax_spans, styles); + if (selection) |range| if (range.mode == .character) applyCharacterSelection(surface, layout.text_col, row, projection, window, range, styles.selection.bg); } fn selectedStyle(style: chasen.TextStyle, selected: bool, styles: RenderStyles) chasen.TextStyle { @@ -1962,15 +2097,15 @@ fn drawGutterLeadInBackground(surface: *chasen.Surface, row: u16, layout: LineLa } } -fn copyStyledScrolledTextAt(surface: *chasen.Surface, col: u16, row: u16, projection: text_projection.Projection, horizontal_scroll: usize, base_style: chasen.TextStyle, spans: syntax_token.LineSpans, styles: RenderStyles) void { +fn copyStyledScrolledTextAt(surface: *chasen.Surface, col: u16, row: u16, projection: text_projection.Projection, window: TextWindow, base_style: chasen.TextStyle, spans: syntax_token.LineSpans, styles: RenderStyles) void { if (col >= surface.size().width) return; const width = surface.size().width - col; - var visible = projection.visibleSegments(horizontal_scroll, width); + var visible = window.segments(projection, width); while (visible.next()) |segment| drawVisibleSegment(surface, col, row, segment, base_style); if (spans.spans.len == 0) return; var span_index: usize = 0; - visible = projection.visibleSegments(horizontal_scroll, width); + visible = window.segments(projection, width); while (visible.next()) |segment| { while (span_index < spans.spans.len and spans.spans[span_index].end <= segment.token.byte_start) span_index += 1; if (span_index >= spans.spans.len) continue; @@ -2012,13 +2147,13 @@ fn applyCharacterSelection( text_col: u16, row: u16, projection: text_projection.Projection, - horizontal_scroll: usize, + window: TextWindow, range: diff_selection.LineVisualRange, background: chasen.Color, ) void { if (text_col >= surface.size().width or range.byte_start >= range.byte_end) return; const width = surface.size().width - text_col; - var visible = projection.visibleSegments(horizontal_scroll, width); + var visible = window.segments(projection, width); while (visible.next()) |segment| { if (segment.token.byte_start >= range.byte_end) break; if (segment.token.byte_end <= range.byte_start) continue; @@ -4297,6 +4432,93 @@ test "raw hunk bytes render without carriage return cells" { }; } +test "line wrap continues long unified lines on following rows without gutter text" { + var ts: chasen.testing.TestSurface = undefined; + try ts.init(32, 7); + defer ts.deinit(); + + const file: diff_parser.FileDiff = .{ + .header = "diff --git a/src/main.zig b/src/main.zig", + .old_path = "src/main.zig", + .new_path = "src/main.zig", + .metadata = &.{}, + .hunks = &.{ + .{ + .old_start = 1, + .old_count = 2, + .new_start = 1, + .new_count = 2, + .section = "", + .lines = &.{ + .{ .kind = .context, .text = "0123456789abcdefghijklmnopqrstuvwxyz", .old_line = 1, .new_line = 1 }, + .{ .kind = .context, .text = "next", .old_line = 2, .new_line = 2 }, + }, + }, + }, + }; + + try renderFile(&ts.surface, file, .{ .requested_mode = .unified, .line_wrap = true, .horizontal_scroll = 4 }); + + try ts.expectCellText(10, 4, "1"); + try ts.expectCellText(14, 4, "0"); + try ts.expectCellText(31, 4, "h"); + try ts.expectCellText(10, 5, " "); + try ts.expectCellText(14, 5, "i"); + try ts.expectCellText(31, 5, "z"); + try ts.expectCellText(10, 6, "2"); + try ts.expectCellText(14, 6, "n"); +} + +test "wrapped row height takes the taller side in side-by-side" { + const long: diff_parser.DiffLine = .{ .kind = .added, .text = "0123456789abcdefghijklmnopqrstuvwxyz", .new_line = 1 }; + const short: diff_parser.DiffLine = .{ .kind = .removed, .text = "x", .old_line = 1 }; + try std.testing.expectEqual(@as(usize, 2), wrappedRowHeight(.{ .unified_line = long }, 31, true)); + try std.testing.expectEqual(@as(usize, 1), wrappedRowHeight(.{ .unified_line = short }, 31, true)); + // 41 columns: 20 per pane, 13 text cells after the 7-cell gutter. + try std.testing.expectEqual(@as(usize, 3), wrappedRowHeight(.{ .side_by_side = .{ .paired = .{ .removed = short, .added = long } } }, 41, true)); +} + +test "wrap rendering preserves parsed grapheme syntax and generated narrow replacement selection" { + var parsed: chasen.testing.TestSurface = undefined; + try parsed.init(32, 7); + defer parsed.deinit(); + const document = try diff_parser.parse(std.testing.allocator, "--- a/a\n+++ b/a\n@@ -0,0 +1 @@\n+0123456789abcdefg界z\n"); + defer document.deinit(std.testing.allocator); + const shape = [_]syntax_provider.FileShape{.{ .hunk_line_counts = &.{1} }}; + var spans = try syntax_provider.allocateEmpty(std.testing.allocator, .{ .files = &shape }); + defer spans.deinit(std.testing.allocator); + syntax_provider.putLineSpans(&spans, .{ .file_index = 0, .hunk_index = 0, .line_index = 0, .side = .new }, .{ + .spans = try std.testing.allocator.dupe(syntax_token.TokenSpan, &.{.{ .start = 17, .end = 20, .role = .keyword }}), + }); + const palette = theme.Palette.default(); + try renderFile(&parsed.surface, document.files[0], .{ .line_wrap = true, .syntax = .initDirect(&spans, 0) }); + try parsed.expectCellText(31, 4, " "); + try parsed.expectCellText(14, 5, "界"); + try parsed.expectCellText(16, 5, "z"); + try parsed.expectCellText(12, 5, " "); + try std.testing.expect(parsed.surface.readCell(14, 5).?.style.fg.eql(palette.color(.syntax_keyword))); + + var generated: chasen.testing.TestSurface = undefined; + try generated.init(15, 6); + defer generated.deinit(); + const bytes = "👩‍💻z"; + var source = try repository_source.Document.initOwnedOrFree(std.testing.allocator, try std.testing.allocator.dupe(u8, bytes), content_fingerprint.Fingerprint.init(bytes)); + defer source.deinit(std.testing.allocator); + try renderGeneratedAddedFile(&generated.surface, "a", &source, .{ + .line_wrap = true, + .selection = .{ + .identity = .{ .generated_file = .{ .path_key = "a" } }, + .content = .{ .source_side = .{ .side = .new, .mode = .character } }, + .start = .{ .hunk_index = 0, .line_index = 0, .leading = 0, .trailing = 11 }, + .end = .{ .hunk_index = 0, .line_index = 0, .leading = 0, .trailing = 11 }, + }, + }); + try generated.expectCellText(14, 3, "�"); + try generated.expectCellText(14, 4, "z"); + try std.testing.expect(generated.surface.readCell(14, 3).?.style.bg.eql(palette.color(.diff_selection_bg))); + try std.testing.expect(!generated.surface.readCell(14, 4).?.style.bg.eql(palette.color(.diff_selection_bg))); +} + test "unified horizontal scroll keeps line numbers and prefix fixed" { var ts: chasen.testing.TestSurface = undefined; try ts.init(32, 5); diff --git a/src/keymap.zig b/src/keymap.zig index 1370f46..3295c72 100644 --- a/src/keymap.zig +++ b/src/keymap.zig @@ -23,6 +23,7 @@ pub const PublicAction = enum { discard, toggle_display_mode, toggle_line_numbers, + toggle_line_wrap, toggle_sidebar, decrease_sidebar_width, increase_sidebar_width, @@ -269,6 +270,7 @@ fn defaultSpec(action: PublicAction) ?KeySpec { .discard => shiftedAscii('d', 'D'), .toggle_display_mode => .{ .plain_codepoint = 'u' }, .toggle_line_numbers => shiftedAscii('l', 'L'), + .toggle_line_wrap => shiftedAscii('w', 'W'), .toggle_sidebar => shiftedAscii('b', 'B'), .decrease_sidebar_width => .{ .plain_codepoint = '<' }, .increase_sidebar_width => .{ .plain_codepoint = '>' }, @@ -477,6 +479,21 @@ test "effective keymap matches overridden actions" { try std.testing.expect(effective.actionForKey(.{ .codepoint = 'c' }) == null); } +test "line wrap uses shifted W and remains configurable" { + const defaults: Effective = .{}; + try std.testing.expectEqual(PublicAction.toggle_line_wrap, defaults.actionForKey(.{ .codepoint = 'W' }).?); + try std.testing.expectEqual(PublicAction.toggle_line_wrap, defaults.actionForKey(.{ .codepoint = 'w', .mods = .{ .shift = true } }).?); + try std.testing.expect(defaults.actionForKey(.{ .codepoint = 'w' }) == null); + try std.testing.expect(defaults.actionForKey(.{ .codepoint = '~' }) == null); + + var config: Config = .{}; + config.set(.toggle_line_wrap, parseKeySpec("x").?); + try std.testing.expect(validateConfig(config)); + const effective = Effective.fromConfig(config); + try std.testing.expectEqual(PublicAction.toggle_line_wrap, effective.actionForKey(.{ .codepoint = 'x' }).?); + try std.testing.expect(effective.actionForKey(.{ .codepoint = 'W' }) == null); +} + test "fetch is unbound by default and configurable" { const defaults: Effective = .{}; try std.testing.expect(defaults.spec(.fetch) == null); diff --git a/src/line_wrap.zig b/src/line_wrap.zig new file mode 100644 index 0000000..31ca1d0 --- /dev/null +++ b/src/line_wrap.zig @@ -0,0 +1,162 @@ +//! Borrowed line fragments. Projection owns grapheme, byte and source TAB geometry. +const std = @import("std"); +const projection = @import("chasen_ui").text_projection; + +pub const Fragment = struct { + tokens: projection.TokenIterator, + cells: projection.CellRange, + bytes: projection.ByteRange, + continuation: bool, + placeholder: bool = false, + replacement: ?projection.Token = null, + + pub fn segments(self: Fragment) Segments { + if (self.replacement) |token| return .{ .replacement = .{ + .token = token, + .global_cells = token.cellRange(), + .viewport_cells = .{ .start = 0, .end = 1 }, + .materialization = .{ .source = "�" }, + } }; + return .{ .projected = .{ + .line = self.tokens.line, + .tokens = self.tokens, + .viewport_start = self.cells.start, + .viewport_end = self.cells.end, + } }; + } + + pub fn hitCell(self: Fragment, cell: usize) ?projection.CellHit { + if (self.placeholder) return null; + var visible = self.segments(); + while (visible.next()) |segment| { + if (cell >= segment.viewport_cells.start and cell < segment.viewport_cells.end) + return .{ .token = segment.token }; + } + // Row padding belongs to this fragment, never to the next row's token. + return .{ .boundary = .{ .byte_offset = self.bytes.end, .cell = self.cells.end } }; + } +}; + +pub const Segments = union(enum) { + projected: projection.VisibleIterator, + replacement: ?projection.VisibleSegment, + + pub fn next(self: *Segments) ?projection.VisibleSegment { + return switch (self.*) { + .projected => |*iterator| iterator.next(), + .replacement => |*segment| blk: { + const result = segment.*; + segment.* = null; + break :blk result; + }, + }; + } +}; + +pub const Iterator = struct { + tokens: projection.TokenIterator, + width: usize, + cell: usize = 0, + byte: usize = 0, + emitted: bool = false, + done: bool = false, + + pub fn init(text: projection.Projection, width: usize) Iterator { + return .{ .tokens = text.tokens(), .width = width }; + } + + pub fn next(self: *Iterator) ?Fragment { + if (self.done) return null; + var fragment: Fragment = .{ + .tokens = self.tokens, + .cells = .{ .start = self.cell, .end = self.cell }, + .bytes = .{ .start = self.byte, .end = self.byte }, + .continuation = self.emitted, + }; + self.emitted = true; + if (self.width == 0) { + self.done = true; + fragment.placeholder = true; + return fragment; + } + while (true) { + const before = self.tokens; + const token = self.tokens.next() orelse { + self.done = true; + break; + }; + const remaining = token.cell_end - self.cell; + if (remaining == 0) { + self.byte = token.byte_end; + fragment.bytes.end = token.byte_end; + continue; + } + const used = self.cell - fragment.cells.start; + const room = self.width -| used; + if (fragment.replacement != null or room == 0) { + self.tokens = before; + break; + } + const tab = std.mem.eql(u8, token.bytes(self.tokens.line), "\t"); + if (!tab and remaining > room) { + if (used != 0) { + self.tokens = before; + break; + } + // The pane cannot show this grapheme at any row boundary. + fragment.replacement = token; + } else if (tab and remaining > room) { + self.cell += room; + self.byte = token.byte_start; + self.tokens = before; + fragment.cells.end = self.cell; + fragment.bytes.end = token.byte_end; + break; + } + self.cell = token.cell_end; + self.byte = token.byte_end; + fragment.cells.end = self.cell; + fragment.bytes.end = token.byte_end; + } + return fragment; + } + + pub fn height(self: Iterator) usize { + var iterator = self; + var count: usize = 0; + while (iterator.next() != null) count += 1; + return count; + } +}; + +test "wrap fragments preserve atomic graphemes and source TAB stops at finite widths" { + const cases = [_]struct { text: []const u8, width: usize, rows: []const []const u8 }{ + .{ .text = "abc界z", .width = 4, .rows = &.{ "abc", "界z" } }, + .{ .text = "abc👩‍💻z", .width = 4, .rows = &.{ "abc", "👩‍💻z" } }, + .{ .text = "e\u{301}\t界z", .width = 3, .rows = &.{ "e\u{301} ", " 界", "z" } }, + .{ .text = "界x", .width = 1, .rows = &.{ "�", "x" } }, + .{ .text = "界", .width = 0, .rows = &.{""} }, + .{ .text = "", .width = 4, .rows = &.{""} }, + .{ .text = "\u{200b}", .width = 1, .rows = &.{""} }, + .{ .text = "x\u{200b}", .width = 1, .rows = &.{"x"} }, + }; + for (cases) |case| { + const text = try projection.Projection.init(case.text, .{ .tab_width = 4 }); + var iterator = Iterator.init(text, case.width); + try std.testing.expectEqual(case.rows.len, iterator.height()); + for (case.rows, 0..) |expected, row| { + const fragment = iterator.next().?; + try std.testing.expectEqual(row != 0, fragment.continuation); + var actual: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer actual.deinit(); + var segments = fragment.segments(); + while (segments.next()) |segment| switch (segment.materialization) { + .source => |bytes| try actual.writer.writeAll(bytes), + .spaces => |count| try actual.writer.splatByteAll(' ', count), + }; + try std.testing.expectEqualStrings(expected, actual.written()); + if (case.width == 0) try std.testing.expect(fragment.hitCell(0) == null); + } + try std.testing.expect(iterator.next() == null); + } +}