fix(daemon): centralize copy mouse coordinates
- commit
- b30c7694f476925458a566eb5046d3f56a25df96
- author
- brice · · 2026-07-15 07:49 UTC
- committer
- brice · · 2026-07-15 09:45 UTC
- parent
- 71f5218
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +455 -161
A internal/usecase/daemon/copy_mouse_map.go +96 -0
| @@ -0,0 +1,96 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 6 | + | "github.com/bnema/vev/internal/usecase/mouse" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // clientTopBarRows is deliberately kept at the client/frame boundary. Layout | |
| 10 | + | // coordinates are content-relative and must cross this boundary exactly once. | |
| 11 | + | const clientTopBarRows = 1 | |
| 12 | + | ||
| 13 | + | type copyMouseGeometry struct { | |
| 14 | + | pane *pane | |
| 15 | + | content domain.Rect // absolute client-frame coordinates | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | type mappedCopyMouse struct { | |
| 19 | + | pane *pane | |
| 20 | + | pos scopy.Pos | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func copyContentToClientRect(content domain.Rect) domain.Rect { | |
| 24 | + | content.Y += clientTopBarRows | |
| 25 | + | return content | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func mapCopyMouse(ev mouse.Event, geometry copyMouseGeometry, viewportTop int, document *scopy.Document, clamp bool) (mappedCopyMouse, bool) { | |
| 29 | + | if document == nil || geometry.pane == nil || geometry.content.Width <= 0 || geometry.content.Height <= 0 { | |
| 30 | + | return mappedCopyMouse{}, false | |
| 31 | + | } | |
| 32 | + | col, row := ev.Col, ev.Row | |
| 33 | + | if clamp { | |
| 34 | + | col = clampInt(col, geometry.content.X, geometry.content.X+geometry.content.Width-1) | |
| 35 | + | row = clampInt(row, geometry.content.Y, geometry.content.Y+geometry.content.Height-1) | |
| 36 | + | } else if !pointInRect(col, row, geometry.content) { | |
| 37 | + | return mappedCopyMouse{}, false | |
| 38 | + | } | |
| 39 | + | pos, ok := document.Normalize(scopy.Pos{Row: viewportTop + row - geometry.content.Y, Col: col - geometry.content.X}) | |
| 40 | + | if !ok { | |
| 41 | + | return mappedCopyMouse{}, false | |
| 42 | + | } | |
| 43 | + | return mappedCopyMouse{pane: geometry.pane, pos: pos}, true | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // hitTestCopyMouseGeometryLocked resolves a new press. Floating geometry is | |
| 47 | + | // exclusive while visible; normal title bars and dividers are intentionally | |
| 48 | + | // not copy content. Caller holds tb.mu. | |
| 49 | + | func hitTestCopyMouseGeometryLocked(tb *tab, cfg domain.FloatingConfig, col, row int) (copyMouseGeometry, bool) { | |
| 50 | + | if tb == nil || row < clientTopBarRows { | |
| 51 | + | return copyMouseGeometry{}, false | |
| 52 | + | } | |
| 53 | + | if p, geometry, visible := tb.visibleFloatingSnapshotLocked(cfg); visible { | |
| 54 | + | content := copyContentToClientRect(geometry.Inner) | |
| 55 | + | if !pointInRect(col, row, content) { | |
| 56 | + | return copyMouseGeometry{}, false | |
| 57 | + | } | |
| 58 | + | return copyMouseGeometry{pane: p, content: content}, true | |
| 59 | + | } | |
| 60 | + | pl, ok := hitTestPlacementLocked(tb, col, row-clientTopBarRows) | |
| 61 | + | if !ok || pl.Collapsed || !pointInRect(col, row-clientTopBarRows, pl.Content) { | |
| 62 | + | return copyMouseGeometry{}, false | |
| 63 | + | } | |
| 64 | + | p := tb.panes[pl.ID] | |
| 65 | + | if p == nil { | |
| 66 | + | return copyMouseGeometry{}, false | |
| 67 | + | } | |
| 68 | + | return copyMouseGeometry{pane: p, content: copyContentToClientRect(pl.Content)}, true | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // copyMouseGeometryForPaneLocked resolves geometry for an existing drag. It | |
| 72 | + | // never hit-tests the new pointer, preventing cross-pane document switches. | |
| 73 | + | func copyMouseGeometryForPaneLocked(tb *tab, cfg domain.FloatingConfig, target *pane) (copyMouseGeometry, bool) { | |
| 74 | + | if tb == nil || target == nil { | |
| 75 | + | return copyMouseGeometry{}, false | |
| 76 | + | } | |
| 77 | + | if p, geometry, visible := tb.visibleFloatingSnapshotLocked(cfg); visible { | |
| 78 | + | if p != target { | |
| 79 | + | return copyMouseGeometry{}, false | |
| 80 | + | } | |
| 81 | + | return copyMouseGeometry{pane: p, content: copyContentToClientRect(geometry.Inner)}, true | |
| 82 | + | } | |
| 83 | + | if tb.panes[target.id] != target { | |
| 84 | + | return copyMouseGeometry{}, false | |
| 85 | + | } | |
| 86 | + | placements, ok := solvedPlacementsLocked(tb) | |
| 87 | + | if !ok { | |
| 88 | + | return copyMouseGeometry{}, false | |
| 89 | + | } | |
| 90 | + | for _, pl := range placements { | |
| 91 | + | if pl.ID == target.id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 { | |
| 92 | + | return copyMouseGeometry{pane: target, content: copyContentToClientRect(pl.Content)}, true | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | return copyMouseGeometry{}, false | |
| 96 | + | } | |
| @@ -0,0 +1,96 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 6 | + | "github.com/bnema/vev/internal/usecase/mouse" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // clientTopBarRows is deliberately kept at the client/frame boundary. Layout | |||
| 10 | + | // coordinates are content-relative and must cross this boundary exactly once. | |||
| 11 | + | const clientTopBarRows = 1 | |||
| 12 | + | ||||
| 13 | + | type copyMouseGeometry struct { | |||
| 14 | + | pane *pane | |||
| 15 | + | content domain.Rect // absolute client-frame coordinates | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | type mappedCopyMouse struct { | |||
| 19 | + | pane *pane | |||
| 20 | + | pos scopy.Pos | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func copyContentToClientRect(content domain.Rect) domain.Rect { | |||
| 24 | + | content.Y += clientTopBarRows | |||
| 25 | + | return content | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func mapCopyMouse(ev mouse.Event, geometry copyMouseGeometry, viewportTop int, document *scopy.Document, clamp bool) (mappedCopyMouse, bool) { | |||
| 29 | + | if document == nil || geometry.pane == nil || geometry.content.Width <= 0 || geometry.content.Height <= 0 { | |||
| 30 | + | return mappedCopyMouse{}, false | |||
| 31 | + | } | |||
| 32 | + | col, row := ev.Col, ev.Row | |||
| 33 | + | if clamp { | |||
| 34 | + | col = clampInt(col, geometry.content.X, geometry.content.X+geometry.content.Width-1) | |||
| 35 | + | row = clampInt(row, geometry.content.Y, geometry.content.Y+geometry.content.Height-1) | |||
| 36 | + | } else if !pointInRect(col, row, geometry.content) { | |||
| 37 | + | return mappedCopyMouse{}, false | |||
| 38 | + | } | |||
| 39 | + | pos, ok := document.Normalize(scopy.Pos{Row: viewportTop + row - geometry.content.Y, Col: col - geometry.content.X}) | |||
| 40 | + | if !ok { | |||
| 41 | + | return mappedCopyMouse{}, false | |||
| 42 | + | } | |||
| 43 | + | return mappedCopyMouse{pane: geometry.pane, pos: pos}, true | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // hitTestCopyMouseGeometryLocked resolves a new press. Floating geometry is | |||
| 47 | + | // exclusive while visible; normal title bars and dividers are intentionally | |||
| 48 | + | // not copy content. Caller holds tb.mu. | |||
| 49 | + | func hitTestCopyMouseGeometryLocked(tb *tab, cfg domain.FloatingConfig, col, row int) (copyMouseGeometry, bool) { | |||
| 50 | + | if tb == nil || row < clientTopBarRows { | |||
| 51 | + | return copyMouseGeometry{}, false | |||
| 52 | + | } | |||
| 53 | + | if p, geometry, visible := tb.visibleFloatingSnapshotLocked(cfg); visible { | |||
| 54 | + | content := copyContentToClientRect(geometry.Inner) | |||
| 55 | + | if !pointInRect(col, row, content) { | |||
| 56 | + | return copyMouseGeometry{}, false | |||
| 57 | + | } | |||
| 58 | + | return copyMouseGeometry{pane: p, content: content}, true | |||
| 59 | + | } | |||
| 60 | + | pl, ok := hitTestPlacementLocked(tb, col, row-clientTopBarRows) | |||
| 61 | + | if !ok || pl.Collapsed || !pointInRect(col, row-clientTopBarRows, pl.Content) { | |||
| 62 | + | return copyMouseGeometry{}, false | |||
| 63 | + | } | |||
| 64 | + | p := tb.panes[pl.ID] | |||
| 65 | + | if p == nil { | |||
| 66 | + | return copyMouseGeometry{}, false | |||
| 67 | + | } | |||
| 68 | + | return copyMouseGeometry{pane: p, content: copyContentToClientRect(pl.Content)}, true | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // copyMouseGeometryForPaneLocked resolves geometry for an existing drag. It | |||
| 72 | + | // never hit-tests the new pointer, preventing cross-pane document switches. | |||
| 73 | + | func copyMouseGeometryForPaneLocked(tb *tab, cfg domain.FloatingConfig, target *pane) (copyMouseGeometry, bool) { | |||
| 74 | + | if tb == nil || target == nil { | |||
| 75 | + | return copyMouseGeometry{}, false | |||
| 76 | + | } | |||
| 77 | + | if p, geometry, visible := tb.visibleFloatingSnapshotLocked(cfg); visible { | |||
| 78 | + | if p != target { | |||
| 79 | + | return copyMouseGeometry{}, false | |||
| 80 | + | } | |||
| 81 | + | return copyMouseGeometry{pane: p, content: copyContentToClientRect(geometry.Inner)}, true | |||
| 82 | + | } | |||
| 83 | + | if tb.panes[target.id] != target { | |||
| 84 | + | return copyMouseGeometry{}, false | |||
| 85 | + | } | |||
| 86 | + | placements, ok := solvedPlacementsLocked(tb) | |||
| 87 | + | if !ok { | |||
| 88 | + | return copyMouseGeometry{}, false | |||
| 89 | + | } | |||
| 90 | + | for _, pl := range placements { | |||
| 91 | + | if pl.ID == target.id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 { | |||
| 92 | + | return copyMouseGeometry{pane: target, content: copyContentToClientRect(pl.Content)}, true | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | return copyMouseGeometry{}, false | |||
| 96 | + | } | |||
A internal/usecase/daemon/copy_mouse_map_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/mouse" | |
| 11 | + | "github.com/bnema/vev/pkg/renderer" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestCopyMouseMapTopBarAndContent(t *testing.T) { | |
| 15 | + | p := newPane("pane", nil, domain.Size{Cols: 5, Rows: 2}) | |
| 16 | + | doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("alpha"), testRow("bravo")}, 5, 2), domain.DefaultWordSeparators) | |
| 17 | + | geometry := copyMouseGeometry{pane: p, content: domain.Rect{X: 0, Y: 1, Width: 5, Height: 2}} | |
| 18 | + | ||
| 19 | + | _, ok := mapCopyMouse(mouse.Event{Col: 0, Row: 0}, geometry, 0, doc, false) | |
| 20 | + | require.False(t, ok) | |
| 21 | + | mapped, ok := mapCopyMouse(mouse.Event{Col: 0, Row: 1}, geometry, 0, doc, false) | |
| 22 | + | require.True(t, ok) | |
| 23 | + | require.Same(t, p, mapped.pane) | |
| 24 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 0}, mapped.pos) | |
| 25 | + | mapped, ok = mapCopyMouse(mouse.Event{Col: 4, Row: 2}, geometry, 0, doc, false) | |
| 26 | + | require.True(t, ok) | |
| 27 | + | require.Equal(t, scopy.Pos{Row: 1, Col: 4}, mapped.pos) | |
| 28 | + | _, ok = mapCopyMouse(mouse.Event{Col: 0, Row: 3}, geometry, 0, doc, false) | |
| 29 | + | require.False(t, ok, "bottom status is not content") | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestCopyMouseMapClampsAndNormalizesWideCells(t *testing.T) { | |
| 33 | + | p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1}) | |
| 34 | + | doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: 'b'}}}, 4, 1), domain.DefaultWordSeparators) | |
| 35 | + | geometry := copyMouseGeometry{pane: p, content: domain.Rect{X: 3, Y: 1, Width: 4, Height: 1}} | |
| 36 | + | ||
| 37 | + | mapped, ok := mapCopyMouse(mouse.Event{Col: 5, Row: 1}, geometry, 0, doc, false) | |
| 38 | + | require.True(t, ok) | |
| 39 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 1}, mapped.pos) | |
| 40 | + | mapped, ok = mapCopyMouse(mouse.Event{Col: 99, Row: 99}, geometry, 0, doc, true) | |
| 41 | + | require.True(t, ok) | |
| 42 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 3}, mapped.pos) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestCopyMouseMapSplitAndFloatingRects(t *testing.T) { | |
| 46 | + | p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 2}) | |
| 47 | + | doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("abcd"), testRow("efgh")}, 4, 2), domain.DefaultWordSeparators) | |
| 48 | + | for _, tc := range []struct { | |
| 49 | + | name string | |
| 50 | + | geometry domain.Rect | |
| 51 | + | ev mouse.Event | |
| 52 | + | want scopy.Pos | |
| 53 | + | ok bool | |
| 54 | + | }{ | |
| 55 | + | {"split content", domain.Rect{X: 21, Y: 2, Width: 4, Height: 2}, mouse.Event{Col: 23, Row: 3}, scopy.Pos{Row: 1, Col: 2}, true}, | |
| 56 | + | {"split title", domain.Rect{X: 21, Y: 2, Width: 4, Height: 2}, mouse.Event{Col: 21, Row: 1}, scopy.Pos{}, false}, | |
| 57 | + | {"floating inner", domain.Rect{X: 10, Y: 5, Width: 4, Height: 2}, mouse.Event{Col: 11, Row: 5}, scopy.Pos{Row: 0, Col: 1}, true}, | |
| 58 | + | {"floating border", domain.Rect{X: 10, Y: 5, Width: 4, Height: 2}, mouse.Event{Col: 9, Row: 5}, scopy.Pos{}, false}, | |
| 59 | + | } { | |
| 60 | + | t.Run(tc.name, func(t *testing.T) { | |
| 61 | + | mapped, ok := mapCopyMouse(tc.ev, copyMouseGeometry{pane: p, content: tc.geometry}, 0, doc, false) | |
| 62 | + | require.Equal(t, tc.ok, ok) | |
| 63 | + | if ok { | |
| 64 | + | require.Equal(t, tc.want, mapped.pos) | |
| 65 | + | } | |
| 66 | + | }) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestCopyMouseGeometryUsesClientFrameExactlyOnce(t *testing.T) { | |
| 71 | + | p, release := newBlockingPTY(t) | |
| 72 | + | defer release() | |
| 73 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 74 | + | tb := sess.activeTab() | |
| 75 | + | ||
| 76 | + | tb.mu.Lock() | |
| 77 | + | geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), 0, 1) | |
| 78 | + | tb.mu.Unlock() | |
| 79 | + | require.True(t, ok) | |
| 80 | + | require.Equal(t, 1, geometry.content.Y) | |
| 81 | + | _, ok = mapCopyMouse(mouse.Event{Col: 0, Row: 1}, geometry, 0, | |
| 82 | + | scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("a")}, 80, 1), domain.DefaultWordSeparators), false) | |
| 83 | + | require.True(t, ok) | |
| 84 | + | } | |
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 10 | + | "github.com/bnema/vev/internal/usecase/mouse" | |||
| 11 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestCopyMouseMapTopBarAndContent(t *testing.T) { | |||
| 15 | + | p := newPane("pane", nil, domain.Size{Cols: 5, Rows: 2}) | |||
| 16 | + | doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("alpha"), testRow("bravo")}, 5, 2), domain.DefaultWordSeparators) | |||
| 17 | + | geometry := copyMouseGeometry{pane: p, content: domain.Rect{X: 0, Y: 1, Width: 5, Height: 2}} | |||
| 18 | + | ||||
| 19 | + | _, ok := mapCopyMouse(mouse.Event{Col: 0, Row: 0}, geometry, 0, doc, false) | |||
| 20 | + | require.False(t, ok) | |||
| 21 | + | mapped, ok := mapCopyMouse(mouse.Event{Col: 0, Row: 1}, geometry, 0, doc, false) | |||
| 22 | + | require.True(t, ok) | |||
| 23 | + | require.Same(t, p, mapped.pane) | |||
| 24 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 0}, mapped.pos) | |||
| 25 | + | mapped, ok = mapCopyMouse(mouse.Event{Col: 4, Row: 2}, geometry, 0, doc, false) | |||
| 26 | + | require.True(t, ok) | |||
| 27 | + | require.Equal(t, scopy.Pos{Row: 1, Col: 4}, mapped.pos) | |||
| 28 | + | _, ok = mapCopyMouse(mouse.Event{Col: 0, Row: 3}, geometry, 0, doc, false) | |||
| 29 | + | require.False(t, ok, "bottom status is not content") | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestCopyMouseMapClampsAndNormalizesWideCells(t *testing.T) { | |||
| 33 | + | p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1}) | |||
| 34 | + | doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: 'b'}}}, 4, 1), domain.DefaultWordSeparators) | |||
| 35 | + | geometry := copyMouseGeometry{pane: p, content: domain.Rect{X: 3, Y: 1, Width: 4, Height: 1}} | |||
| 36 | + | ||||
| 37 | + | mapped, ok := mapCopyMouse(mouse.Event{Col: 5, Row: 1}, geometry, 0, doc, false) | |||
| 38 | + | require.True(t, ok) | |||
| 39 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 1}, mapped.pos) | |||
| 40 | + | mapped, ok = mapCopyMouse(mouse.Event{Col: 99, Row: 99}, geometry, 0, doc, true) | |||
| 41 | + | require.True(t, ok) | |||
| 42 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 3}, mapped.pos) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestCopyMouseMapSplitAndFloatingRects(t *testing.T) { | |||
| 46 | + | p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 2}) | |||
| 47 | + | doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("abcd"), testRow("efgh")}, 4, 2), domain.DefaultWordSeparators) | |||
| 48 | + | for _, tc := range []struct { | |||
| 49 | + | name string | |||
| 50 | + | geometry domain.Rect | |||
| 51 | + | ev mouse.Event | |||
| 52 | + | want scopy.Pos | |||
| 53 | + | ok bool | |||
| 54 | + | }{ | |||
| 55 | + | {"split content", domain.Rect{X: 21, Y: 2, Width: 4, Height: 2}, mouse.Event{Col: 23, Row: 3}, scopy.Pos{Row: 1, Col: 2}, true}, | |||
| 56 | + | {"split title", domain.Rect{X: 21, Y: 2, Width: 4, Height: 2}, mouse.Event{Col: 21, Row: 1}, scopy.Pos{}, false}, | |||
| 57 | + | {"floating inner", domain.Rect{X: 10, Y: 5, Width: 4, Height: 2}, mouse.Event{Col: 11, Row: 5}, scopy.Pos{Row: 0, Col: 1}, true}, | |||
| 58 | + | {"floating border", domain.Rect{X: 10, Y: 5, Width: 4, Height: 2}, mouse.Event{Col: 9, Row: 5}, scopy.Pos{}, false}, | |||
| 59 | + | } { | |||
| 60 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 61 | + | mapped, ok := mapCopyMouse(tc.ev, copyMouseGeometry{pane: p, content: tc.geometry}, 0, doc, false) | |||
| 62 | + | require.Equal(t, tc.ok, ok) | |||
| 63 | + | if ok { | |||
| 64 | + | require.Equal(t, tc.want, mapped.pos) | |||
| 65 | + | } | |||
| 66 | + | }) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestCopyMouseGeometryUsesClientFrameExactlyOnce(t *testing.T) { | |||
| 71 | + | p, release := newBlockingPTY(t) | |||
| 72 | + | defer release() | |||
| 73 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 74 | + | tb := sess.activeTab() | |||
| 75 | + | ||||
| 76 | + | tb.mu.Lock() | |||
| 77 | + | geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), 0, 1) | |||
| 78 | + | tb.mu.Unlock() | |||
| 79 | + | require.True(t, ok) | |||
| 80 | + | require.Equal(t, 1, geometry.content.Y) | |||
| 81 | + | _, ok = mapCopyMouse(mouse.Event{Col: 0, Row: 1}, geometry, 0, | |||
| 82 | + | scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("a")}, 80, 1), domain.DefaultWordSeparators), false) | |||
| 83 | + | require.True(t, ok) | |||
| 84 | + | } | |||
M internal/usecase/daemon/copymode.go +23 -28
| @@ -91,9 +91,11 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||
| 91 | 91 | rt.copyPane = p | |
| 92 | 92 | rt.copySearch = nil | |
| 93 | 93 | rt.copySearchPending = nil | |
| 94 | - | rt.copyPressRowValid = false | |
| 95 | - | rt.copyDragging = false | |
| 96 | - | rt.normalMousePressValid = false | |
| 94 | + | if activate != nil { | |
| 95 | + | rt.clearCopyPointerForTransferLocked() | |
| 96 | + | } else { | |
| 97 | + | rt.invalidateCopyPointerLocked(true) | |
| 98 | + | } | |
| 97 | 99 | rt.copyMu.Unlock() | |
| 98 | 100 | valid := sess.activeTab() == tb | |
| 99 | 101 | if valid { | |
| @@ -118,48 +120,41 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||
| 118 | 120 | return true | |
| 119 | 121 | } | |
| 120 | 122 | ||
| 121 | - | // copyMouse remains row-compatible until centralized pointer mapping supplies columns. | |
| 122 | - | func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |
| 123 | + | // copyMouse receives already mapped client-frame coordinates. The caller owns | |
| 124 | + | // geometry resolution; this function only mutates immutable-document state. | |
| 125 | + | func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, mapped mappedCopyMouse) { | |
| 123 | 126 | rt := ac.overlays | |
| 124 | 127 | if ev.Button != mouse.Left { | |
| 125 | 128 | return | |
| 126 | 129 | } | |
| 127 | 130 | rt.copyMu.Lock() | |
| 128 | - | if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil { | |
| 129 | - | rt.copyMu.Unlock() | |
| 130 | - | return | |
| 131 | - | } | |
| 132 | - | document := rt.copyDocument | |
| 133 | - | if ev.Row >= document.Height() { | |
| 134 | - | if ev.Type == mouse.Press { | |
| 135 | - | rt.copyPressRowValid = false | |
| 136 | - | rt.copyDragging = false | |
| 131 | + | if rt.copyMode == nil || rt.copyPane != mapped.pane || rt.copyDocument == nil || rt.copyDocument != rt.copyMode.Document() { | |
| 132 | + | if ev.Type == mouse.Release { | |
| 133 | + | rt.invalidateCopyPointerLocked(false) | |
| 137 | 134 | } | |
| 138 | 135 | rt.copyMu.Unlock() | |
| 139 | 136 | return | |
| 140 | 137 | } | |
| 141 | - | pos := scopy.Pos{Row: rt.copyMode.ViewportTop + ev.Row, Col: 0} | |
| 142 | 138 | changed := false | |
| 143 | 139 | switch ev.Type { | |
| 144 | 140 | case mouse.Press: | |
| 145 | - | if rt.copyMode.SetPosition(pos) { | |
| 146 | - | changed = true | |
| 141 | + | changed = rt.copyMode.SetPosition(mapped.pos) | |
| 142 | + | pointer := rt.copyPointer | |
| 143 | + | if !pointer.valid || pointer.pane != mapped.pane || pointer.document != rt.copyDocument { | |
| 144 | + | rt.beginCopyPointerLocked(copyPointerState{pane: mapped.pane, document: rt.copyDocument, press: mapped.pos}) | |
| 147 | 145 | } | |
| 148 | - | rt.copyPressRow = rt.copyMode.Cursor().Row | |
| 149 | - | rt.copyPressRowValid = true | |
| 150 | - | rt.copyDragging = false | |
| 151 | 146 | case mouse.Motion: | |
| 152 | - | if rt.copyPressRowValid { | |
| 153 | - | if !rt.copyDragging { | |
| 154 | - | rt.copyMode.SetPosition(scopy.Pos{Row: rt.copyPressRow, Col: 0}) | |
| 155 | - | rt.copyMode.ToggleLineSelection() | |
| 156 | - | rt.copyDragging = true | |
| 147 | + | pointer := rt.copyPointer | |
| 148 | + | if pointer.valid && pointer.pane == mapped.pane && pointer.document == rt.copyDocument { | |
| 149 | + | if !pointer.dragging { | |
| 150 | + | rt.copyMode.StartCharacterSelection(pointer.press) | |
| 151 | + | pointer.dragging = true | |
| 157 | 152 | } | |
| 158 | - | changed = rt.copyMode.MoveRows(pos.Row-rt.copyMode.Cursor().Row) || changed | |
| 153 | + | changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed | |
| 154 | + | rt.copyPointer = pointer | |
| 159 | 155 | } | |
| 160 | 156 | case mouse.Release: | |
| 161 | - | rt.copyPressRowValid = false | |
| 162 | - | rt.copyDragging = false | |
| 157 | + | rt.invalidateCopyPointerLocked(false) | |
| 163 | 158 | } | |
| 164 | 159 | rt.copyMu.Unlock() | |
| 165 | 160 | if changed { | |
| @@ -91,9 +91,11 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||||
| 91 | rt.copyPane = p | 91 | rt.copyPane = p | ||
| 92 | rt.copySearch = nil | 92 | rt.copySearch = nil | ||
| 93 | rt.copySearchPending = nil | 93 | rt.copySearchPending = nil | ||
| 94 | - | rt.copyPressRowValid = false | 94 | + | if activate != nil { |
| 95 | - | rt.copyDragging = false | 95 | + | rt.clearCopyPointerForTransferLocked() |
| 96 | - | rt.normalMousePressValid = false | 96 | + | } else { |
| 97 | + | rt.invalidateCopyPointerLocked(true) | |||
| 98 | + | } | |||
| 97 | rt.copyMu.Unlock() | 99 | rt.copyMu.Unlock() | ||
| 98 | valid := sess.activeTab() == tb | 100 | valid := sess.activeTab() == tb | ||
| 99 | if valid { | 101 | if valid { | ||
| @@ -118,48 +120,41 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||||
| 118 | return true | 120 | return true | ||
| 119 | } | 121 | } | ||
| 120 | 122 | ||||
| 121 | - | // copyMouse remains row-compatible until centralized pointer mapping supplies columns. | 123 | + | // copyMouse receives already mapped client-frame coordinates. The caller owns |
| 122 | - | func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | 124 | + | // geometry resolution; this function only mutates immutable-document state. |
| 125 | + | func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, mapped mappedCopyMouse) { | |||
| 123 | rt := ac.overlays | 126 | rt := ac.overlays | ||
| 124 | if ev.Button != mouse.Left { | 127 | if ev.Button != mouse.Left { | ||
| 125 | return | 128 | return | ||
| 126 | } | 129 | } | ||
| 127 | rt.copyMu.Lock() | 130 | rt.copyMu.Lock() | ||
| 128 | - | if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil { | 131 | + | if rt.copyMode == nil || rt.copyPane != mapped.pane || rt.copyDocument == nil || rt.copyDocument != rt.copyMode.Document() { |
| 129 | - | rt.copyMu.Unlock() | 132 | + | if ev.Type == mouse.Release { |
| 130 | - | return | 133 | + | rt.invalidateCopyPointerLocked(false) |
| 131 | - | } | |||
| 132 | - | document := rt.copyDocument | |||
| 133 | - | if ev.Row >= document.Height() { | |||
| 134 | - | if ev.Type == mouse.Press { | |||
| 135 | - | rt.copyPressRowValid = false | |||
| 136 | - | rt.copyDragging = false | |||
| 137 | } | 134 | } | ||
| 138 | rt.copyMu.Unlock() | 135 | rt.copyMu.Unlock() | ||
| 139 | return | 136 | return | ||
| 140 | } | 137 | } | ||
| 141 | - | pos := scopy.Pos{Row: rt.copyMode.ViewportTop + ev.Row, Col: 0} | |||
| 142 | changed := false | 138 | changed := false | ||
| 143 | switch ev.Type { | 139 | switch ev.Type { | ||
| 144 | case mouse.Press: | 140 | case mouse.Press: | ||
| 145 | - | if rt.copyMode.SetPosition(pos) { | 141 | + | changed = rt.copyMode.SetPosition(mapped.pos) |
| 146 | - | changed = true | 142 | + | pointer := rt.copyPointer |
| 143 | + | if !pointer.valid || pointer.pane != mapped.pane || pointer.document != rt.copyDocument { | |||
| 144 | + | rt.beginCopyPointerLocked(copyPointerState{pane: mapped.pane, document: rt.copyDocument, press: mapped.pos}) | |||
| 147 | } | 145 | } | ||
| 148 | - | rt.copyPressRow = rt.copyMode.Cursor().Row | |||
| 149 | - | rt.copyPressRowValid = true | |||
| 150 | - | rt.copyDragging = false | |||
| 151 | case mouse.Motion: | 146 | case mouse.Motion: | ||
| 152 | - | if rt.copyPressRowValid { | 147 | + | pointer := rt.copyPointer |
| 153 | - | if !rt.copyDragging { | 148 | + | if pointer.valid && pointer.pane == mapped.pane && pointer.document == rt.copyDocument { |
| 154 | - | rt.copyMode.SetPosition(scopy.Pos{Row: rt.copyPressRow, Col: 0}) | 149 | + | if !pointer.dragging { |
| 155 | - | rt.copyMode.ToggleLineSelection() | 150 | + | rt.copyMode.StartCharacterSelection(pointer.press) |
| 156 | - | rt.copyDragging = true | 151 | + | pointer.dragging = true |
| 157 | } | 152 | } | ||
| 158 | - | changed = rt.copyMode.MoveRows(pos.Row-rt.copyMode.Cursor().Row) || changed | 153 | + | changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed |
| 154 | + | rt.copyPointer = pointer | |||
| 159 | } | 155 | } | ||
| 160 | case mouse.Release: | 156 | case mouse.Release: | ||
| 161 | - | rt.copyPressRowValid = false | 157 | + | rt.invalidateCopyPointerLocked(false) |
| 162 | - | rt.copyDragging = false | |||
| 163 | } | 158 | } | ||
| 164 | rt.copyMu.Unlock() | 159 | rt.copyMu.Unlock() | ||
| 165 | if changed { | 160 | if changed { | ||
M internal/usecase/daemon/copymode_test.go +2 -2
| @@ -864,7 +864,7 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) { | |||
| 864 | 864 | copy(pane.screen.Frame.Row(0), testRow("alpha")) | |
| 865 | 865 | copy(pane.screen.Frame.Row(1), testRow("bravo")) | |
| 866 | 866 | ||
| 867 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |
| 867 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M")) | |
| 868 | 868 | mustOutputData(t, sends) | |
| 869 | 869 | require.NotNil(t, ac.overlays.copyMode) | |
| 870 | 870 | ac.overlays.copyMu.Lock() | |
| @@ -876,6 +876,6 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) { | |||
| 876 | 876 | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 877 | 877 | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 878 | 878 | require.NoError(t, err) | |
| 879 | - | want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha\nbravo")) + "\x07" | |
| 879 | + | want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha"+strings.Repeat(" ", 75)+"\nb")) + "\x07" | |
| 880 | 880 | require.Equal(t, want, string(msg.Data)) | |
| 881 | 881 | } | |
| @@ -864,7 +864,7 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) { | |||||
| 864 | copy(pane.screen.Frame.Row(0), testRow("alpha")) | 864 | copy(pane.screen.Frame.Row(0), testRow("alpha")) | ||
| 865 | copy(pane.screen.Frame.Row(1), testRow("bravo")) | 865 | copy(pane.screen.Frame.Row(1), testRow("bravo")) | ||
| 866 | 866 | ||||
| 867 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | 867 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M")) |
| 868 | mustOutputData(t, sends) | 868 | mustOutputData(t, sends) | ||
| 869 | require.NotNil(t, ac.overlays.copyMode) | 869 | require.NotNil(t, ac.overlays.copyMode) | ||
| 870 | ac.overlays.copyMu.Lock() | 870 | ac.overlays.copyMu.Lock() | ||
| @@ -876,6 +876,6 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) { | |||||
| 876 | out := awaitFrame(t, sends, ports.MsgOutput) | 876 | out := awaitFrame(t, sends, ports.MsgOutput) | ||
| 877 | msg, err := ports.UnmarshalOutput(out.Payload) | 877 | msg, err := ports.UnmarshalOutput(out.Payload) | ||
| 878 | require.NoError(t, err) | 878 | require.NoError(t, err) | ||
| 879 | - | want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha\nbravo")) + "\x07" | 879 | + | want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha"+strings.Repeat(" ", 75)+"\nb")) + "\x07" |
| 880 | require.Equal(t, want, string(msg.Data)) | 880 | require.Equal(t, want, string(msg.Data)) | ||
| 881 | } | 881 | } | ||
M internal/usecase/daemon/input.go +125 -94
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "bytes" | |
| 6 | 6 | "strconv" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/keys" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/layout" | |
| @@ -51,70 +52,13 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 51 | 52 | if rt.copySearchActive() { | |
| 52 | 53 | return | |
| 53 | 54 | } | |
| 54 | - | copyPane := copyTargetPane(rt) | |
| 55 | - | tb.mu.Lock() | |
| 56 | - | floatingPane, geometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |
| 57 | - | floatingCopy := floatingVisible && copyPane == floatingPane | |
| 58 | - | tb.mu.Unlock() | |
| 59 | - | if floatingCopy { | |
| 60 | - | contentRow := ev.Row - 1 | |
| 61 | - | if !pointInRect(ev.Col, contentRow, geometry.Inner) { | |
| 62 | - | return | |
| 63 | - | } | |
| 64 | - | // Match the multi-pane translation: hand copyMouse a zero-based | |
| 65 | - | // popup row so a click selects exactly the row under the pointer. | |
| 66 | - | ev.Row = contentRow | |
| 67 | - | ev = translateMouseEvent(ev, geometry.Inner.X, geometry.Inner.Y) | |
| 68 | - | switch ev.Button { | |
| 69 | - | case mouse.Left: | |
| 70 | - | d.copyMouse(sess, ac, ev) | |
| 71 | - | case mouse.WheelUp: | |
| 72 | - | d.copyWheel(sess, ac, -3) | |
| 73 | - | case mouse.WheelDown: | |
| 74 | - | d.copyWheel(sess, ac, 3) | |
| 75 | - | } | |
| 76 | - | return | |
| 77 | - | } | |
| 78 | - | if ev.Button == mouse.Left { | |
| 79 | - | contentRow := ev.Row - 1 | |
| 80 | - | tb.mu.Lock() | |
| 81 | - | p := tb.focusedPane() | |
| 82 | - | var pl layout.Placement | |
| 83 | - | var ok bool | |
| 84 | - | multi := len(tb.panes) > 1 | |
| 85 | - | if p != nil { | |
| 86 | - | pl, ok = focusedPlacementLocked(tb) | |
| 87 | - | } | |
| 88 | - | if ok && multi && !pointInRect(ev.Col, contentRow, pl.Content) && ev.Type == mouse.Press { | |
| 89 | - | if target, hit := hitTestPlacementLocked(tb, ev.Col, contentRow); hit { | |
| 90 | - | oldFocus := tb.tree.Focus | |
| 91 | - | focusPlacementLocked(tb, target.ID) | |
| 92 | - | d.applyLayoutLocked(tb) | |
| 93 | - | tb.mu.Unlock() | |
| 94 | - | d.exitCopyMode(ac) | |
| 95 | - | if target.ID != oldFocus { | |
| 96 | - | d.refreshPaneTitleOnFocus(sess, target.ID) | |
| 97 | - | } | |
| 98 | - | d.invalidateRender(sess, ac, true, "input.go") | |
| 99 | - | return | |
| 100 | - | } | |
| 101 | - | } | |
| 102 | - | tb.mu.Unlock() | |
| 103 | - | if ok && multi { | |
| 104 | - | clampedCol := clampInt(ev.Col, pl.Content.X, pl.Content.X+pl.Content.Width-1) | |
| 105 | - | clampedRow := clampInt(contentRow, pl.Content.Y, pl.Content.Y+pl.Content.Height-1) | |
| 106 | - | ev.Col = clampedCol | |
| 107 | - | ev.Row = clampedRow | |
| 108 | - | ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y) | |
| 109 | - | } | |
| 110 | - | } | |
| 111 | 55 | switch ev.Button { | |
| 112 | - | case mouse.Left: | |
| 113 | - | d.copyMouse(sess, ac, ev) | |
| 114 | 56 | case mouse.WheelUp: | |
| 115 | 57 | d.copyWheel(sess, ac, -3) | |
| 116 | 58 | case mouse.WheelDown: | |
| 117 | 59 | d.copyWheel(sess, ac, 3) | |
| 60 | + | case mouse.Left: | |
| 61 | + | d.handleActiveCopyMouse(sess, ac, tb, ev) | |
| 118 | 62 | } | |
| 119 | 63 | return | |
| 120 | 64 | } | |
| @@ -192,6 +136,92 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 192 | 136 | d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused) | |
| 193 | 137 | } | |
| 194 | 138 | ||
| 139 | + | // handleActiveCopyMouse keeps every drag tied to the pane/document captured at | |
| 140 | + | // press time. Geometry is resolved after copyMu is released to preserve the | |
| 141 | + | // daemon's copyMu -> tab/pane lock prohibition. | |
| 142 | + | func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) { | |
| 143 | + | rt := ac.overlays | |
| 144 | + | rt.copyMu.Lock() | |
| 145 | + | mode, target, document := rt.copyMode, rt.copyPane, rt.copyDocument | |
| 146 | + | pointer := rt.copyPointer | |
| 147 | + | rt.copyMu.Unlock() | |
| 148 | + | if mode == nil || target == nil || document == nil { | |
| 149 | + | return | |
| 150 | + | } | |
| 151 | + | cfg := d.currentFloatingConfig() | |
| 152 | + | // Once pressed, use the committed rectangle captured by that press. A | |
| 153 | + | // later floating/layout change must not turn an in-flight drag into a hit | |
| 154 | + | // on a different pane. | |
| 155 | + | if ev.Type != mouse.Press && pointer.valid && pointer.pane == target && pointer.document == document { | |
| 156 | + | mapped, ok := mapCopyMouse(ev, pointer.geometry, mode.ViewportTop, document, true) | |
| 157 | + | if ev.Type == mouse.Release && !ok { | |
| 158 | + | rt.copyMu.Lock() | |
| 159 | + | rt.invalidateCopyPointerLocked(false) | |
| 160 | + | rt.copyMu.Unlock() | |
| 161 | + | return | |
| 162 | + | } | |
| 163 | + | if ok { | |
| 164 | + | d.copyMouse(sess, ac, ev, mapped) | |
| 165 | + | } | |
| 166 | + | return | |
| 167 | + | } | |
| 168 | + | tb.mu.Lock() | |
| 169 | + | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, target) | |
| 170 | + | if !ok && ev.Type == mouse.Press { | |
| 171 | + | // Preserve the established press-on-another-pane behavior: copy mode | |
| 172 | + | // exits rather than retargeting its immutable document. | |
| 173 | + | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != target { | |
| 174 | + | if tb.tree != nil { | |
| 175 | + | focusPlacementLocked(tb, hit.pane.id) | |
| 176 | + | d.applyLayoutLocked(tb) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | tb.mu.Unlock() | |
| 181 | + | if !ok { | |
| 182 | + | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |
| 183 | + | rt.copyMu.Lock() | |
| 184 | + | rt.invalidateCopyPointerLocked(ev.Type == mouse.Press) | |
| 185 | + | rt.copyMu.Unlock() | |
| 186 | + | } | |
| 187 | + | return | |
| 188 | + | } | |
| 189 | + | clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release | |
| 190 | + | mapped, mappedOK := mapCopyMouse(ev, geometry, mode.ViewportTop, document, clamp) | |
| 191 | + | if !mappedOK { | |
| 192 | + | if ev.Type == mouse.Press { | |
| 193 | + | otherPane := false | |
| 194 | + | tb.mu.Lock() | |
| 195 | + | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != target { | |
| 196 | + | otherPane = true | |
| 197 | + | if tb.tree != nil { | |
| 198 | + | focusPlacementLocked(tb, hit.pane.id) | |
| 199 | + | d.applyLayoutLocked(tb) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | tb.mu.Unlock() | |
| 203 | + | if otherPane { | |
| 204 | + | d.exitCopyMode(ac) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |
| 208 | + | rt.copyMu.Lock() | |
| 209 | + | rt.invalidateCopyPointerLocked(ev.Type == mouse.Press) | |
| 210 | + | rt.copyMu.Unlock() | |
| 211 | + | } | |
| 212 | + | return | |
| 213 | + | } | |
| 214 | + | if ev.Type == mouse.Press { | |
| 215 | + | // Keep the resolved frame geometry with the pointer installed by copyMouse. | |
| 216 | + | rt.copyMu.Lock() | |
| 217 | + | if rt.copyMode == mode && rt.copyPane == target && rt.copyDocument == document { | |
| 218 | + | rt.beginCopyPointerLocked(copyPointerState{pane: target, document: document, geometry: geometry, press: mapped.pos}) | |
| 219 | + | } | |
| 220 | + | rt.copyMu.Unlock() | |
| 221 | + | } | |
| 222 | + | d.copyMouse(sess, ac, ev, mapped) | |
| 223 | + | } | |
| 224 | + | ||
| 195 | 225 | func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) { | |
| 196 | 226 | if p == nil { | |
| 197 | 227 | return | |
| @@ -199,12 +229,9 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 199 | 229 | rt := ac.overlays | |
| 200 | 230 | p.mu.Lock() | |
| 201 | 231 | childRows := p.screen.Frame.Height | |
| 232 | + | childCols := p.screen.Frame.Width | |
| 202 | 233 | mouseMode, mouseSGR := p.screen.MouseMode() | |
| 203 | 234 | altScreen := p.screen.AltScreenActive() | |
| 204 | - | scrollbackRows := 0 | |
| 205 | - | if p.history != nil { | |
| 206 | - | scrollbackRows = p.history.Len() | |
| 207 | - | } | |
| 208 | 235 | p.mu.Unlock() | |
| 209 | 236 | ||
| 210 | 237 | if mouseMode != 0 { | |
| @@ -221,58 +248,62 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 221 | 248 | ||
| 222 | 249 | switch ev.Button { | |
| 223 | 250 | case mouse.Left: | |
| 251 | + | // At this point ev is pane-local in X and retains its client-frame Y, | |
| 252 | + | // so this synthetic geometry crosses the top-bar boundary once. | |
| 253 | + | geometry := copyMouseGeometry{pane: p, content: domain.Rect{X: 0, Y: clientTopBarRows, Width: childCols, Height: childRows}} | |
| 224 | 254 | switch ev.Type { | |
| 225 | 255 | case mouse.Press: | |
| 226 | - | if altScreen || ev.Row >= childRows { | |
| 256 | + | if altScreen { | |
| 227 | 257 | rt.copyMu.Lock() | |
| 228 | - | rt.normalMousePressValid = false | |
| 258 | + | rt.invalidateCopyPointerLocked(true) | |
| 259 | + | rt.copyMu.Unlock() | |
| 260 | + | return | |
| 261 | + | } | |
| 262 | + | p.mu.Lock() | |
| 263 | + | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 264 | + | p.mu.Unlock() | |
| 265 | + | document := scopy.NewDocument(snapshot, d.currentCopyConfig().WordSeparators) | |
| 266 | + | mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false) | |
| 267 | + | if !ok { | |
| 268 | + | rt.copyMu.Lock() | |
| 269 | + | rt.invalidateCopyPointerLocked(true) | |
| 229 | 270 | rt.copyMu.Unlock() | |
| 230 | 271 | return | |
| 231 | 272 | } | |
| 232 | 273 | rt.copyMu.Lock() | |
| 233 | - | rt.normalMousePressRow = ev.Row | |
| 234 | - | rt.normalMousePressTop = scrollbackRows | |
| 235 | - | rt.normalMousePressValid = true | |
| 274 | + | rt.beginCopyPointerLocked(copyPointerState{pane: p, document: document, geometry: geometry, press: mapped.pos}) | |
| 236 | 275 | rt.copyMu.Unlock() | |
| 237 | 276 | case mouse.Motion: | |
| 238 | - | if altScreen || ev.Row >= childRows { | |
| 277 | + | if altScreen { | |
| 239 | 278 | return | |
| 240 | 279 | } | |
| 241 | 280 | rt.copyMu.Lock() | |
| 242 | - | pressValid := rt.normalMousePressValid | |
| 243 | - | pressRow := rt.normalMousePressRow | |
| 244 | - | pressTop := rt.normalMousePressTop | |
| 281 | + | pointer := rt.copyPointer | |
| 245 | 282 | rt.copyMu.Unlock() | |
| 246 | - | if !pressValid { | |
| 283 | + | if !pointer.valid || pointer.pane != p { | |
| 247 | 284 | return | |
| 248 | 285 | } | |
| 249 | - | ||
| 250 | - | // The immutable snapshot is the only live-pane access needed for drag entry. | |
| 251 | - | p.mu.Lock() | |
| 252 | - | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 253 | - | p.mu.Unlock() | |
| 254 | - | cfg := d.currentCopyConfig() | |
| 255 | - | document := scopy.NewDocument(snapshot, cfg.WordSeparators) | |
| 256 | - | tb := sess.activeTab() | |
| 257 | - | if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) { | |
| 258 | - | mode.SetPosition(scopy.Pos{Row: pressTop + pressRow, Col: 0}) | |
| 259 | - | mode.ToggleLineSelection() | |
| 260 | - | mode.MoveRows(document.Len() - document.Height() + ev.Row - mode.Cursor().Row) | |
| 261 | - | }, nil) { | |
| 286 | + | mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, true) | |
| 287 | + | if !ok { | |
| 262 | 288 | return | |
| 263 | 289 | } | |
| 264 | - | rt.copyMu.Lock() | |
| 265 | - | // Row-compatible temporary pointer state; phase 4 supplies exact columns. | |
| 266 | - | if rt.copyPane == p && rt.copyDocument == document && rt.copyMode != nil { | |
| 267 | - | rt.copyPressRow = pressTop + pressRow | |
| 268 | - | rt.copyPressRowValid = true | |
| 269 | - | rt.copyDragging = true | |
| 290 | + | tb := sess.activeTab() | |
| 291 | + | if !d.publishCopyMode(sess, ac, tb, p, pointer.document, func(mode *scopy.Mode) { | |
| 292 | + | mode.StartCharacterSelection(pointer.press) | |
| 293 | + | mode.ExtendCharacterSelection(mapped.pos) | |
| 294 | + | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |
| 295 | + | if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { | |
| 296 | + | return | |
| 297 | + | } | |
| 298 | + | pointer.dragging = true | |
| 299 | + | runtime.copyPointer = pointer | |
| 300 | + | }) { | |
| 301 | + | return | |
| 270 | 302 | } | |
| 271 | - | rt.copyMu.Unlock() | |
| 272 | 303 | d.invalidateRender(sess, ac, true, "input.go") | |
| 273 | 304 | case mouse.Release: | |
| 274 | 305 | rt.copyMu.Lock() | |
| 275 | - | rt.normalMousePressValid = false | |
| 306 | + | rt.invalidateCopyPointerLocked(false) | |
| 276 | 307 | rt.copyMu.Unlock() | |
| 277 | 308 | } | |
| 278 | 309 | case mouse.WheelUp: | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "bytes" | 5 | "bytes" | ||
| 6 | "strconv" | 6 | "strconv" | ||
| 7 | 7 | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | scopy "github.com/bnema/vev/internal/usecase/copy" | 9 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 9 | "github.com/bnema/vev/internal/usecase/keys" | 10 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 10 | "github.com/bnema/vev/internal/usecase/layout" | 11 | "github.com/bnema/vev/internal/usecase/layout" | ||
| @@ -51,70 +52,13 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 51 | if rt.copySearchActive() { | 52 | if rt.copySearchActive() { | ||
| 52 | return | 53 | return | ||
| 53 | } | 54 | } | ||
| 54 | - | copyPane := copyTargetPane(rt) | |||
| 55 | - | tb.mu.Lock() | |||
| 56 | - | floatingPane, geometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |||
| 57 | - | floatingCopy := floatingVisible && copyPane == floatingPane | |||
| 58 | - | tb.mu.Unlock() | |||
| 59 | - | if floatingCopy { | |||
| 60 | - | contentRow := ev.Row - 1 | |||
| 61 | - | if !pointInRect(ev.Col, contentRow, geometry.Inner) { | |||
| 62 | - | return | |||
| 63 | - | } | |||
| 64 | - | // Match the multi-pane translation: hand copyMouse a zero-based | |||
| 65 | - | // popup row so a click selects exactly the row under the pointer. | |||
| 66 | - | ev.Row = contentRow | |||
| 67 | - | ev = translateMouseEvent(ev, geometry.Inner.X, geometry.Inner.Y) | |||
| 68 | - | switch ev.Button { | |||
| 69 | - | case mouse.Left: | |||
| 70 | - | d.copyMouse(sess, ac, ev) | |||
| 71 | - | case mouse.WheelUp: | |||
| 72 | - | d.copyWheel(sess, ac, -3) | |||
| 73 | - | case mouse.WheelDown: | |||
| 74 | - | d.copyWheel(sess, ac, 3) | |||
| 75 | - | } | |||
| 76 | - | return | |||
| 77 | - | } | |||
| 78 | - | if ev.Button == mouse.Left { | |||
| 79 | - | contentRow := ev.Row - 1 | |||
| 80 | - | tb.mu.Lock() | |||
| 81 | - | p := tb.focusedPane() | |||
| 82 | - | var pl layout.Placement | |||
| 83 | - | var ok bool | |||
| 84 | - | multi := len(tb.panes) > 1 | |||
| 85 | - | if p != nil { | |||
| 86 | - | pl, ok = focusedPlacementLocked(tb) | |||
| 87 | - | } | |||
| 88 | - | if ok && multi && !pointInRect(ev.Col, contentRow, pl.Content) && ev.Type == mouse.Press { | |||
| 89 | - | if target, hit := hitTestPlacementLocked(tb, ev.Col, contentRow); hit { | |||
| 90 | - | oldFocus := tb.tree.Focus | |||
| 91 | - | focusPlacementLocked(tb, target.ID) | |||
| 92 | - | d.applyLayoutLocked(tb) | |||
| 93 | - | tb.mu.Unlock() | |||
| 94 | - | d.exitCopyMode(ac) | |||
| 95 | - | if target.ID != oldFocus { | |||
| 96 | - | d.refreshPaneTitleOnFocus(sess, target.ID) | |||
| 97 | - | } | |||
| 98 | - | d.invalidateRender(sess, ac, true, "input.go") | |||
| 99 | - | return | |||
| 100 | - | } | |||
| 101 | - | } | |||
| 102 | - | tb.mu.Unlock() | |||
| 103 | - | if ok && multi { | |||
| 104 | - | clampedCol := clampInt(ev.Col, pl.Content.X, pl.Content.X+pl.Content.Width-1) | |||
| 105 | - | clampedRow := clampInt(contentRow, pl.Content.Y, pl.Content.Y+pl.Content.Height-1) | |||
| 106 | - | ev.Col = clampedCol | |||
| 107 | - | ev.Row = clampedRow | |||
| 108 | - | ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y) | |||
| 109 | - | } | |||
| 110 | - | } | |||
| 111 | switch ev.Button { | 55 | switch ev.Button { | ||
| 112 | - | case mouse.Left: | |||
| 113 | - | d.copyMouse(sess, ac, ev) | |||
| 114 | case mouse.WheelUp: | 56 | case mouse.WheelUp: | ||
| 115 | d.copyWheel(sess, ac, -3) | 57 | d.copyWheel(sess, ac, -3) | ||
| 116 | case mouse.WheelDown: | 58 | case mouse.WheelDown: | ||
| 117 | d.copyWheel(sess, ac, 3) | 59 | d.copyWheel(sess, ac, 3) | ||
| 60 | + | case mouse.Left: | |||
| 61 | + | d.handleActiveCopyMouse(sess, ac, tb, ev) | |||
| 118 | } | 62 | } | ||
| 119 | return | 63 | return | ||
| 120 | } | 64 | } | ||
| @@ -192,6 +136,92 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 192 | d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused) | 136 | d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused) | ||
| 193 | } | 137 | } | ||
| 194 | 138 | ||||
| 139 | + | // handleActiveCopyMouse keeps every drag tied to the pane/document captured at | |||
| 140 | + | // press time. Geometry is resolved after copyMu is released to preserve the | |||
| 141 | + | // daemon's copyMu -> tab/pane lock prohibition. | |||
| 142 | + | func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) { | |||
| 143 | + | rt := ac.overlays | |||
| 144 | + | rt.copyMu.Lock() | |||
| 145 | + | mode, target, document := rt.copyMode, rt.copyPane, rt.copyDocument | |||
| 146 | + | pointer := rt.copyPointer | |||
| 147 | + | rt.copyMu.Unlock() | |||
| 148 | + | if mode == nil || target == nil || document == nil { | |||
| 149 | + | return | |||
| 150 | + | } | |||
| 151 | + | cfg := d.currentFloatingConfig() | |||
| 152 | + | // Once pressed, use the committed rectangle captured by that press. A | |||
| 153 | + | // later floating/layout change must not turn an in-flight drag into a hit | |||
| 154 | + | // on a different pane. | |||
| 155 | + | if ev.Type != mouse.Press && pointer.valid && pointer.pane == target && pointer.document == document { | |||
| 156 | + | mapped, ok := mapCopyMouse(ev, pointer.geometry, mode.ViewportTop, document, true) | |||
| 157 | + | if ev.Type == mouse.Release && !ok { | |||
| 158 | + | rt.copyMu.Lock() | |||
| 159 | + | rt.invalidateCopyPointerLocked(false) | |||
| 160 | + | rt.copyMu.Unlock() | |||
| 161 | + | return | |||
| 162 | + | } | |||
| 163 | + | if ok { | |||
| 164 | + | d.copyMouse(sess, ac, ev, mapped) | |||
| 165 | + | } | |||
| 166 | + | return | |||
| 167 | + | } | |||
| 168 | + | tb.mu.Lock() | |||
| 169 | + | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, target) | |||
| 170 | + | if !ok && ev.Type == mouse.Press { | |||
| 171 | + | // Preserve the established press-on-another-pane behavior: copy mode | |||
| 172 | + | // exits rather than retargeting its immutable document. | |||
| 173 | + | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != target { | |||
| 174 | + | if tb.tree != nil { | |||
| 175 | + | focusPlacementLocked(tb, hit.pane.id) | |||
| 176 | + | d.applyLayoutLocked(tb) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | tb.mu.Unlock() | |||
| 181 | + | if !ok { | |||
| 182 | + | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |||
| 183 | + | rt.copyMu.Lock() | |||
| 184 | + | rt.invalidateCopyPointerLocked(ev.Type == mouse.Press) | |||
| 185 | + | rt.copyMu.Unlock() | |||
| 186 | + | } | |||
| 187 | + | return | |||
| 188 | + | } | |||
| 189 | + | clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release | |||
| 190 | + | mapped, mappedOK := mapCopyMouse(ev, geometry, mode.ViewportTop, document, clamp) | |||
| 191 | + | if !mappedOK { | |||
| 192 | + | if ev.Type == mouse.Press { | |||
| 193 | + | otherPane := false | |||
| 194 | + | tb.mu.Lock() | |||
| 195 | + | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != target { | |||
| 196 | + | otherPane = true | |||
| 197 | + | if tb.tree != nil { | |||
| 198 | + | focusPlacementLocked(tb, hit.pane.id) | |||
| 199 | + | d.applyLayoutLocked(tb) | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | tb.mu.Unlock() | |||
| 203 | + | if otherPane { | |||
| 204 | + | d.exitCopyMode(ac) | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |||
| 208 | + | rt.copyMu.Lock() | |||
| 209 | + | rt.invalidateCopyPointerLocked(ev.Type == mouse.Press) | |||
| 210 | + | rt.copyMu.Unlock() | |||
| 211 | + | } | |||
| 212 | + | return | |||
| 213 | + | } | |||
| 214 | + | if ev.Type == mouse.Press { | |||
| 215 | + | // Keep the resolved frame geometry with the pointer installed by copyMouse. | |||
| 216 | + | rt.copyMu.Lock() | |||
| 217 | + | if rt.copyMode == mode && rt.copyPane == target && rt.copyDocument == document { | |||
| 218 | + | rt.beginCopyPointerLocked(copyPointerState{pane: target, document: document, geometry: geometry, press: mapped.pos}) | |||
| 219 | + | } | |||
| 220 | + | rt.copyMu.Unlock() | |||
| 221 | + | } | |||
| 222 | + | d.copyMouse(sess, ac, ev, mapped) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 195 | func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) { | 225 | func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) { | ||
| 196 | if p == nil { | 226 | if p == nil { | ||
| 197 | return | 227 | return | ||
| @@ -199,12 +229,9 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||||
| 199 | rt := ac.overlays | 229 | rt := ac.overlays | ||
| 200 | p.mu.Lock() | 230 | p.mu.Lock() | ||
| 201 | childRows := p.screen.Frame.Height | 231 | childRows := p.screen.Frame.Height | ||
| 232 | + | childCols := p.screen.Frame.Width | |||
| 202 | mouseMode, mouseSGR := p.screen.MouseMode() | 233 | mouseMode, mouseSGR := p.screen.MouseMode() | ||
| 203 | altScreen := p.screen.AltScreenActive() | 234 | altScreen := p.screen.AltScreenActive() | ||
| 204 | - | scrollbackRows := 0 | |||
| 205 | - | if p.history != nil { | |||
| 206 | - | scrollbackRows = p.history.Len() | |||
| 207 | - | } | |||
| 208 | p.mu.Unlock() | 235 | p.mu.Unlock() | ||
| 209 | 236 | ||||
| 210 | if mouseMode != 0 { | 237 | if mouseMode != 0 { | ||
| @@ -221,58 +248,62 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||||
| 221 | 248 | ||||
| 222 | switch ev.Button { | 249 | switch ev.Button { | ||
| 223 | case mouse.Left: | 250 | case mouse.Left: | ||
| 251 | + | // At this point ev is pane-local in X and retains its client-frame Y, | |||
| 252 | + | // so this synthetic geometry crosses the top-bar boundary once. | |||
| 253 | + | geometry := copyMouseGeometry{pane: p, content: domain.Rect{X: 0, Y: clientTopBarRows, Width: childCols, Height: childRows}} | |||
| 224 | switch ev.Type { | 254 | switch ev.Type { | ||
| 225 | case mouse.Press: | 255 | case mouse.Press: | ||
| 226 | - | if altScreen || ev.Row >= childRows { | 256 | + | if altScreen { |
| 227 | rt.copyMu.Lock() | 257 | rt.copyMu.Lock() | ||
| 228 | - | rt.normalMousePressValid = false | 258 | + | rt.invalidateCopyPointerLocked(true) |
| 259 | + | rt.copyMu.Unlock() | |||
| 260 | + | return | |||
| 261 | + | } | |||
| 262 | + | p.mu.Lock() | |||
| 263 | + | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame) | |||
| 264 | + | p.mu.Unlock() | |||
| 265 | + | document := scopy.NewDocument(snapshot, d.currentCopyConfig().WordSeparators) | |||
| 266 | + | mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false) | |||
| 267 | + | if !ok { | |||
| 268 | + | rt.copyMu.Lock() | |||
| 269 | + | rt.invalidateCopyPointerLocked(true) | |||
| 229 | rt.copyMu.Unlock() | 270 | rt.copyMu.Unlock() | ||
| 230 | return | 271 | return | ||
| 231 | } | 272 | } | ||
| 232 | rt.copyMu.Lock() | 273 | rt.copyMu.Lock() | ||
| 233 | - | rt.normalMousePressRow = ev.Row | 274 | + | rt.beginCopyPointerLocked(copyPointerState{pane: p, document: document, geometry: geometry, press: mapped.pos}) |
| 234 | - | rt.normalMousePressTop = scrollbackRows | |||
| 235 | - | rt.normalMousePressValid = true | |||
| 236 | rt.copyMu.Unlock() | 275 | rt.copyMu.Unlock() | ||
| 237 | case mouse.Motion: | 276 | case mouse.Motion: | ||
| 238 | - | if altScreen || ev.Row >= childRows { | 277 | + | if altScreen { |
| 239 | return | 278 | return | ||
| 240 | } | 279 | } | ||
| 241 | rt.copyMu.Lock() | 280 | rt.copyMu.Lock() | ||
| 242 | - | pressValid := rt.normalMousePressValid | 281 | + | pointer := rt.copyPointer |
| 243 | - | pressRow := rt.normalMousePressRow | |||
| 244 | - | pressTop := rt.normalMousePressTop | |||
| 245 | rt.copyMu.Unlock() | 282 | rt.copyMu.Unlock() | ||
| 246 | - | if !pressValid { | 283 | + | if !pointer.valid || pointer.pane != p { |
| 247 | return | 284 | return | ||
| 248 | } | 285 | } | ||
| 249 | - | 286 | + | mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, true) | |
| 250 | - | // The immutable snapshot is the only live-pane access needed for drag entry. | 287 | + | if !ok { |
| 251 | - | p.mu.Lock() | |||
| 252 | - | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame) | |||
| 253 | - | p.mu.Unlock() | |||
| 254 | - | cfg := d.currentCopyConfig() | |||
| 255 | - | document := scopy.NewDocument(snapshot, cfg.WordSeparators) | |||
| 256 | - | tb := sess.activeTab() | |||
| 257 | - | if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) { | |||
| 258 | - | mode.SetPosition(scopy.Pos{Row: pressTop + pressRow, Col: 0}) | |||
| 259 | - | mode.ToggleLineSelection() | |||
| 260 | - | mode.MoveRows(document.Len() - document.Height() + ev.Row - mode.Cursor().Row) | |||
| 261 | - | }, nil) { | |||
| 262 | return | 288 | return | ||
| 263 | } | 289 | } | ||
| 264 | - | rt.copyMu.Lock() | 290 | + | tb := sess.activeTab() |
| 265 | - | // Row-compatible temporary pointer state; phase 4 supplies exact columns. | 291 | + | if !d.publishCopyMode(sess, ac, tb, p, pointer.document, func(mode *scopy.Mode) { |
| 266 | - | if rt.copyPane == p && rt.copyDocument == document && rt.copyMode != nil { | 292 | + | mode.StartCharacterSelection(pointer.press) |
| 267 | - | rt.copyPressRow = pressTop + pressRow | 293 | + | mode.ExtendCharacterSelection(mapped.pos) |
| 268 | - | rt.copyPressRowValid = true | 294 | + | }, func(runtime *overlayRuntime, mode *scopy.Mode) { |
| 269 | - | rt.copyDragging = true | 295 | + | if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { |
| 296 | + | return | |||
| 297 | + | } | |||
| 298 | + | pointer.dragging = true | |||
| 299 | + | runtime.copyPointer = pointer | |||
| 300 | + | }) { | |||
| 301 | + | return | |||
| 270 | } | 302 | } | ||
| 271 | - | rt.copyMu.Unlock() | |||
| 272 | d.invalidateRender(sess, ac, true, "input.go") | 303 | d.invalidateRender(sess, ac, true, "input.go") | ||
| 273 | case mouse.Release: | 304 | case mouse.Release: | ||
| 274 | rt.copyMu.Lock() | 305 | rt.copyMu.Lock() | ||
| 275 | - | rt.normalMousePressValid = false | 306 | + | rt.invalidateCopyPointerLocked(false) |
| 276 | rt.copyMu.Unlock() | 307 | rt.copyMu.Unlock() | ||
| 277 | } | 308 | } | ||
| 278 | case mouse.WheelUp: | 309 | case mouse.WheelUp: | ||
M internal/usecase/daemon/input_test.go +39 -14
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "github.com/bnema/vev/internal/domain" | |
| 16 | 16 | "github.com/bnema/vev/internal/ports" | |
| 17 | 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 18 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 18 | 19 | "github.com/bnema/vev/internal/usecase/keys" | |
| 19 | 20 | "github.com/bnema/vev/internal/usecase/layout" | |
| 20 | 21 | "github.com/bnema/vev/internal/usecase/picker" | |
| @@ -945,9 +946,9 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 945 | 946 | } | |
| 946 | 947 | ||
| 947 | 948 | sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006l\x1b[?1000l")) | |
| 948 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |
| 949 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 949 | 950 | require.Nil(t, ac.overlays.copyMode, "press alone must still not enter visual mode") | |
| 950 | - | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 951 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;4M")) | |
| 951 | 952 | mustOutputData(t, sends) | |
| 952 | 953 | require.NotNil(t, ac.overlays.copyMode) | |
| 953 | 954 | selection := ac.overlays.copyMode.Selection() | |
| @@ -960,6 +961,30 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 960 | 961 | require.Equal(t, 2, hi) | |
| 961 | 962 | } | |
| 962 | 963 | ||
| 964 | + | func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) { | |
| 965 | + | p, _ := newBlockingPTY(t) | |
| 966 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 967 | + | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha")) | |
| 968 | + | d.enterCopyMode(sess, ac) | |
| 969 | + | mustOutputData(t, sends) | |
| 970 | + | ||
| 971 | + | // SGR is one based: Cy2 is the first body row. Dragging from column 3 | |
| 972 | + | // back to column 1 is the same stream as the forward endpoints 1..3. | |
| 973 | + | d.handleInput(sess, ac, []byte("\x1b[<0;4;2M\x1b[<32;2;2M")) | |
| 974 | + | mustOutputData(t, sends) | |
| 975 | + | d.handleInput(sess, ac, []byte("\x1b[<0;2;2m")) | |
| 976 | + | d.handleInput(sess, ac, []byte("y")) | |
| 977 | + | var msg ports.Output | |
| 978 | + | require.Eventually(t, func() bool { | |
| 979 | + | frame := awaitFrame(t, sends, ports.MsgOutput) | |
| 980 | + | var err error | |
| 981 | + | msg, err = ports.UnmarshalOutput(frame.Payload) | |
| 982 | + | return err == nil && string(msg.Data) == string(scopy.OSC52("lph")[0]) | |
| 983 | + | }, 2*time.Second, 5*time.Millisecond) | |
| 984 | + | require.Equal(t, scopy.OSC52("lph")[0], msg.Data) | |
| 985 | + | require.Nil(t, ac.overlays.copyPointer.pane) | |
| 986 | + | } | |
| 987 | + | ||
| 963 | 988 | func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |
| 964 | 989 | p, _ := newBlockingPTY(t) | |
| 965 | 990 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| @@ -969,7 +994,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |||
| 969 | 994 | ||
| 970 | 995 | d.enterCopyMode(sess, ac) | |
| 971 | 996 | mustOutputData(t, sends) | |
| 972 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |
| 997 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M")) | |
| 973 | 998 | mustOutputData(t, sends) | |
| 974 | 999 | require.NotNil(t, ac.overlays.copyMode) | |
| 975 | 1000 | selection := ac.overlays.copyMode.Selection() | |
| @@ -989,7 +1014,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |||
| 989 | 1014 | }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data) | |
| 990 | 1015 | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a")) | |
| 991 | 1016 | require.NoError(t, err) | |
| 992 | - | require.Equal(t, "alpha\nbravo", string(decoded)) | |
| 1017 | + | require.Equal(t, "alpha"+strings.Repeat(" ", 75)+"\nb", string(decoded)) | |
| 993 | 1018 | require.Nil(t, ac.overlays.copyMode) | |
| 994 | 1019 | ||
| 995 | 1020 | exitPaint := string(mustOutputData(t, sends)) | |
| @@ -1009,16 +1034,16 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) { | |||
| 1009 | 1034 | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | |
| 1010 | 1035 | ||
| 1011 | 1036 | // Press on a content row establishes a (soon to be stale) anchor. | |
| 1012 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |
| 1037 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 1013 | 1038 | require.Nil(t, ac.overlays.copyMode) | |
| 1014 | 1039 | ||
| 1015 | 1040 | // Release lands on the status row: must still clear the press state, | |
| 1016 | 1041 | // regardless of the row it landed on. | |
| 1017 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;24m")) | |
| 1042 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;25m")) | |
| 1018 | 1043 | ||
| 1019 | 1044 | // A brand new button hold starts with a Press on the status row: must | |
| 1020 | 1045 | // clear (not silently preserve) any prior press state. | |
| 1021 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;24M")) | |
| 1046 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;25M")) | |
| 1022 | 1047 | ||
| 1023 | 1048 | // Motion onto a content row must not resurrect a stale anchor. | |
| 1024 | 1049 | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| @@ -1044,7 +1069,7 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |||
| 1044 | 1069 | mustOutputData(t, sends) | |
| 1045 | 1070 | ||
| 1046 | 1071 | // Full drag: press row0, motion to row1 -> selection [0,1]. | |
| 1047 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |
| 1072 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M")) | |
| 1048 | 1073 | mustOutputData(t, sends) | |
| 1049 | 1074 | require.NotNil(t, ac.overlays.copyMode) | |
| 1050 | 1075 | selection := ac.overlays.copyMode.Selection() | |
| @@ -1057,8 +1082,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |||
| 1057 | 1082 | require.Equal(t, 1, hi) | |
| 1058 | 1083 | ||
| 1059 | 1084 | // Release, then a new button hold starting with Press on the status row. | |
| 1060 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;2m")) | |
| 1061 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;24M")) | |
| 1085 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;3m")) | |
| 1086 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;25M")) | |
| 1062 | 1087 | ||
| 1063 | 1088 | // Motion onto a content row must be a no-op: the status-row press must | |
| 1064 | 1089 | // have cleared copyPressRowValid/copyDragging. | |
| @@ -1087,8 +1112,8 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |||
| 1087 | 1112 | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | |
| 1088 | 1113 | ||
| 1089 | 1114 | // Press on a content row while scrollback is empty: the anchor is | |
| 1090 | - | // content-stable at pressTop(0)+pressRow(0) = row 0. | |
| 1091 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |
| 1115 | + | // content-stable at the first body row = document row 0. | |
| 1116 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 1092 | 1117 | ||
| 1093 | 1118 | // Simulate 5 lines evicted into scrollback between the Press and the | |
| 1094 | 1119 | // first Motion (e.g. the child kept producing output). | |
| @@ -1110,7 +1135,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |||
| 1110 | 1135 | lo, hi = hi, lo | |
| 1111 | 1136 | } | |
| 1112 | 1137 | require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time") | |
| 1113 | - | require.Equal(t, 7, hi, "extend target must track the pointer's current content row, not a stale scrollback offset") | |
| 1138 | + | require.Equal(t, 1, hi, "the press-owned immutable document keeps the mapped endpoint stable") | |
| 1114 | 1139 | } | |
| 1115 | 1140 | ||
| 1116 | 1141 | func TestMouseSplitReportPreservesOrder(t *testing.T) { | |
| @@ -1219,7 +1244,7 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) { | |||
| 1219 | 1244 | d.enterCopyMode(sess, ac) | |
| 1220 | 1245 | mustOutputData(t, sends) | |
| 1221 | 1246 | ||
| 1222 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;12M")) | |
| 1247 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;12M")) | |
| 1223 | 1248 | mustOutputData(t, sends) | |
| 1224 | 1249 | ||
| 1225 | 1250 | selection := ac.overlays.copyMode.Selection() | |
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | "github.com/bnema/vev/internal/domain" | 15 | "github.com/bnema/vev/internal/domain" | ||
| 16 | "github.com/bnema/vev/internal/ports" | 16 | "github.com/bnema/vev/internal/ports" | ||
| 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 18 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 18 | "github.com/bnema/vev/internal/usecase/keys" | 19 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 19 | "github.com/bnema/vev/internal/usecase/layout" | 20 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 20 | "github.com/bnema/vev/internal/usecase/picker" | 21 | "github.com/bnema/vev/internal/usecase/picker" | ||
| @@ -945,9 +946,9 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||||
| 945 | } | 946 | } | ||
| 946 | 947 | ||||
| 947 | sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006l\x1b[?1000l")) | 948 | sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006l\x1b[?1000l")) | ||
| 948 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | 949 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) |
| 949 | require.Nil(t, ac.overlays.copyMode, "press alone must still not enter visual mode") | 950 | require.Nil(t, ac.overlays.copyMode, "press alone must still not enter visual mode") | ||
| 950 | - | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | 951 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;4M")) |
| 951 | mustOutputData(t, sends) | 952 | mustOutputData(t, sends) | ||
| 952 | require.NotNil(t, ac.overlays.copyMode) | 953 | require.NotNil(t, ac.overlays.copyMode) | ||
| 953 | selection := ac.overlays.copyMode.Selection() | 954 | selection := ac.overlays.copyMode.Selection() | ||
| @@ -960,6 +961,30 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||||
| 960 | require.Equal(t, 2, hi) | 961 | require.Equal(t, 2, hi) | ||
| 961 | } | 962 | } | ||
| 962 | 963 | ||||
| 964 | + | func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) { | |||
| 965 | + | p, _ := newBlockingPTY(t) | |||
| 966 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 967 | + | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha")) | |||
| 968 | + | d.enterCopyMode(sess, ac) | |||
| 969 | + | mustOutputData(t, sends) | |||
| 970 | + | ||||
| 971 | + | // SGR is one based: Cy2 is the first body row. Dragging from column 3 | |||
| 972 | + | // back to column 1 is the same stream as the forward endpoints 1..3. | |||
| 973 | + | d.handleInput(sess, ac, []byte("\x1b[<0;4;2M\x1b[<32;2;2M")) | |||
| 974 | + | mustOutputData(t, sends) | |||
| 975 | + | d.handleInput(sess, ac, []byte("\x1b[<0;2;2m")) | |||
| 976 | + | d.handleInput(sess, ac, []byte("y")) | |||
| 977 | + | var msg ports.Output | |||
| 978 | + | require.Eventually(t, func() bool { | |||
| 979 | + | frame := awaitFrame(t, sends, ports.MsgOutput) | |||
| 980 | + | var err error | |||
| 981 | + | msg, err = ports.UnmarshalOutput(frame.Payload) | |||
| 982 | + | return err == nil && string(msg.Data) == string(scopy.OSC52("lph")[0]) | |||
| 983 | + | }, 2*time.Second, 5*time.Millisecond) | |||
| 984 | + | require.Equal(t, scopy.OSC52("lph")[0], msg.Data) | |||
| 985 | + | require.Nil(t, ac.overlays.copyPointer.pane) | |||
| 986 | + | } | |||
| 987 | + | ||||
| 963 | func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | 988 | func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | ||
| 964 | p, _ := newBlockingPTY(t) | 989 | p, _ := newBlockingPTY(t) | ||
| 965 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 990 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| @@ -969,7 +994,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |||||
| 969 | 994 | ||||
| 970 | d.enterCopyMode(sess, ac) | 995 | d.enterCopyMode(sess, ac) | ||
| 971 | mustOutputData(t, sends) | 996 | mustOutputData(t, sends) | ||
| 972 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | 997 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M")) |
| 973 | mustOutputData(t, sends) | 998 | mustOutputData(t, sends) | ||
| 974 | require.NotNil(t, ac.overlays.copyMode) | 999 | require.NotNil(t, ac.overlays.copyMode) | ||
| 975 | selection := ac.overlays.copyMode.Selection() | 1000 | selection := ac.overlays.copyMode.Selection() | ||
| @@ -989,7 +1014,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |||||
| 989 | }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data) | 1014 | }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data) | ||
| 990 | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a")) | 1015 | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a")) | ||
| 991 | require.NoError(t, err) | 1016 | require.NoError(t, err) | ||
| 992 | - | require.Equal(t, "alpha\nbravo", string(decoded)) | 1017 | + | require.Equal(t, "alpha"+strings.Repeat(" ", 75)+"\nb", string(decoded)) |
| 993 | require.Nil(t, ac.overlays.copyMode) | 1018 | require.Nil(t, ac.overlays.copyMode) | ||
| 994 | 1019 | ||||
| 995 | exitPaint := string(mustOutputData(t, sends)) | 1020 | exitPaint := string(mustOutputData(t, sends)) | ||
| @@ -1009,16 +1034,16 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) { | |||||
| 1009 | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | 1034 | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | ||
| 1010 | 1035 | ||||
| 1011 | // Press on a content row establishes a (soon to be stale) anchor. | 1036 | // Press on a content row establishes a (soon to be stale) anchor. | ||
| 1012 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | 1037 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) |
| 1013 | require.Nil(t, ac.overlays.copyMode) | 1038 | require.Nil(t, ac.overlays.copyMode) | ||
| 1014 | 1039 | ||||
| 1015 | // Release lands on the status row: must still clear the press state, | 1040 | // Release lands on the status row: must still clear the press state, | ||
| 1016 | // regardless of the row it landed on. | 1041 | // regardless of the row it landed on. | ||
| 1017 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;24m")) | 1042 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;25m")) |
| 1018 | 1043 | ||||
| 1019 | // A brand new button hold starts with a Press on the status row: must | 1044 | // A brand new button hold starts with a Press on the status row: must | ||
| 1020 | // clear (not silently preserve) any prior press state. | 1045 | // clear (not silently preserve) any prior press state. | ||
| 1021 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;24M")) | 1046 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;25M")) |
| 1022 | 1047 | ||||
| 1023 | // Motion onto a content row must not resurrect a stale anchor. | 1048 | // Motion onto a content row must not resurrect a stale anchor. | ||
| 1024 | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | 1049 | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | ||
| @@ -1044,7 +1069,7 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |||||
| 1044 | mustOutputData(t, sends) | 1069 | mustOutputData(t, sends) | ||
| 1045 | 1070 | ||||
| 1046 | // Full drag: press row0, motion to row1 -> selection [0,1]. | 1071 | // Full drag: press row0, motion to row1 -> selection [0,1]. | ||
| 1047 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | 1072 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M")) |
| 1048 | mustOutputData(t, sends) | 1073 | mustOutputData(t, sends) | ||
| 1049 | require.NotNil(t, ac.overlays.copyMode) | 1074 | require.NotNil(t, ac.overlays.copyMode) | ||
| 1050 | selection := ac.overlays.copyMode.Selection() | 1075 | selection := ac.overlays.copyMode.Selection() | ||
| @@ -1057,8 +1082,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |||||
| 1057 | require.Equal(t, 1, hi) | 1082 | require.Equal(t, 1, hi) | ||
| 1058 | 1083 | ||||
| 1059 | // Release, then a new button hold starting with Press on the status row. | 1084 | // Release, then a new button hold starting with Press on the status row. | ||
| 1060 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;2m")) | 1085 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;3m")) |
| 1061 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;24M")) | 1086 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;25M")) |
| 1062 | 1087 | ||||
| 1063 | // Motion onto a content row must be a no-op: the status-row press must | 1088 | // Motion onto a content row must be a no-op: the status-row press must | ||
| 1064 | // have cleared copyPressRowValid/copyDragging. | 1089 | // have cleared copyPressRowValid/copyDragging. | ||
| @@ -1087,8 +1112,8 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |||||
| 1087 | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | 1112 | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | ||
| 1088 | 1113 | ||||
| 1089 | // Press on a content row while scrollback is empty: the anchor is | 1114 | // Press on a content row while scrollback is empty: the anchor is | ||
| 1090 | - | // content-stable at pressTop(0)+pressRow(0) = row 0. | 1115 | + | // content-stable at the first body row = document row 0. |
| 1091 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | 1116 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) |
| 1092 | 1117 | ||||
| 1093 | // Simulate 5 lines evicted into scrollback between the Press and the | 1118 | // Simulate 5 lines evicted into scrollback between the Press and the | ||
| 1094 | // first Motion (e.g. the child kept producing output). | 1119 | // first Motion (e.g. the child kept producing output). | ||
| @@ -1110,7 +1135,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |||||
| 1110 | lo, hi = hi, lo | 1135 | lo, hi = hi, lo | ||
| 1111 | } | 1136 | } | ||
| 1112 | require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time") | 1137 | require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time") | ||
| 1113 | - | require.Equal(t, 7, hi, "extend target must track the pointer's current content row, not a stale scrollback offset") | 1138 | + | require.Equal(t, 1, hi, "the press-owned immutable document keeps the mapped endpoint stable") |
| 1114 | } | 1139 | } | ||
| 1115 | 1140 | ||||
| 1116 | func TestMouseSplitReportPreservesOrder(t *testing.T) { | 1141 | func TestMouseSplitReportPreservesOrder(t *testing.T) { | ||
| @@ -1219,7 +1244,7 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) { | |||||
| 1219 | d.enterCopyMode(sess, ac) | 1244 | d.enterCopyMode(sess, ac) | ||
| 1220 | mustOutputData(t, sends) | 1245 | mustOutputData(t, sends) | ||
| 1221 | 1246 | ||||
| 1222 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;12M")) | 1247 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;12M")) |
| 1223 | mustOutputData(t, sends) | 1248 | mustOutputData(t, sends) | ||
| 1224 | 1249 | ||||
| 1225 | selection := ac.overlays.copyMode.Selection() | 1250 | selection := ac.overlays.copyMode.Selection() | ||
M internal/usecase/daemon/mouse_panes.go +0 -3
| @@ -144,8 +144,5 @@ func (d *Daemon) exitCopyMode(ac *attachedClient) { | |||
| 144 | 144 | } | |
| 145 | 145 | rt.copyMu.Lock() | |
| 146 | 146 | rt.clearCopyModeLocked() | |
| 147 | - | rt.copyPressRowValid = false | |
| 148 | - | rt.copyDragging = false | |
| 149 | - | rt.normalMousePressValid = false | |
| 150 | 147 | rt.copyMu.Unlock() | |
| 151 | 148 | } | |
| @@ -144,8 +144,5 @@ func (d *Daemon) exitCopyMode(ac *attachedClient) { | |||||
| 144 | } | 144 | } | ||
| 145 | rt.copyMu.Lock() | 145 | rt.copyMu.Lock() | ||
| 146 | rt.clearCopyModeLocked() | 146 | rt.clearCopyModeLocked() | ||
| 147 | - | rt.copyPressRowValid = false | |||
| 148 | - | rt.copyDragging = false | |||
| 149 | - | rt.normalMousePressValid = false | |||
| 150 | rt.copyMu.Unlock() | 147 | rt.copyMu.Unlock() | ||
| 151 | } | 148 | } | ||
M internal/usecase/daemon/overlay_runtime.go +63 -20
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "sync" | |
| 5 | + | "time" | |
| 5 | 6 | ||
| 6 | 7 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/palette" | |
| @@ -33,22 +34,38 @@ type overlayRuntime struct { | |||
| 33 | 34 | promptSubmit func(string) error | |
| 34 | 35 | promptPending []byte | |
| 35 | 36 | ||
| 36 | - | copyMu sync.Mutex | |
| 37 | - | copyMode *scopy.Mode | |
| 38 | - | copyCandidate *scopy.Mode | |
| 39 | - | copyDocument *scopy.Document | |
| 40 | - | copyPane *pane | |
| 41 | - | copyPending []byte | |
| 42 | - | copyESC pendingByteTimer | |
| 43 | - | copySearch *visualsearch.Model | |
| 44 | - | copySearchPending []byte | |
| 45 | - | copyFeedback string | |
| 46 | - | copyPressRow int | |
| 47 | - | copyPressRowValid bool | |
| 48 | - | copyDragging bool | |
| 49 | - | normalMousePressRow int | |
| 50 | - | normalMousePressTop int | |
| 51 | - | normalMousePressValid bool | |
| 37 | + | copyMu sync.Mutex | |
| 38 | + | copyMode *scopy.Mode | |
| 39 | + | copyCandidate *scopy.Mode | |
| 40 | + | copyDocument *scopy.Document | |
| 41 | + | copyPane *pane | |
| 42 | + | copyPending []byte | |
| 43 | + | copyESC pendingByteTimer | |
| 44 | + | copySearch *visualsearch.Model | |
| 45 | + | copySearchPending []byte | |
| 46 | + | copyFeedback string | |
| 47 | + | copyPointer copyPointerState | |
| 48 | + | copyClick copyClickCandidate | |
| 49 | + | copyPointerEpoch uint64 | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | type copyPointerState struct { | |
| 53 | + | valid bool | |
| 54 | + | epoch uint64 | |
| 55 | + | pane *pane | |
| 56 | + | document *scopy.Document | |
| 57 | + | geometry copyMouseGeometry | |
| 58 | + | press scopy.Pos | |
| 59 | + | dragging bool | |
| 60 | + | wordDrag bool | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | type copyClickCandidate struct { | |
| 64 | + | valid bool | |
| 65 | + | pane *pane | |
| 66 | + | pos scopy.Pos | |
| 67 | + | at time.Time | |
| 68 | + | dragged bool | |
| 52 | 69 | } | |
| 53 | 70 | ||
| 54 | 71 | func newOverlayRuntime(ac *attachedClient) *overlayRuntime { | |
| @@ -107,6 +124,28 @@ func (rt *overlayRuntime) copySearchActive() bool { | |||
| 107 | 124 | return rt.copySearch != nil | |
| 108 | 125 | } | |
| 109 | 126 | ||
| 127 | + | func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) { | |
| 128 | + | rt.copyPointerEpoch++ | |
| 129 | + | pointer.epoch = rt.copyPointerEpoch | |
| 130 | + | pointer.valid = true | |
| 131 | + | rt.copyPointer = pointer | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) { | |
| 135 | + | rt.copyPointerEpoch++ | |
| 136 | + | rt.copyPointer = copyPointerState{} | |
| 137 | + | if clearClick { | |
| 138 | + | rt.copyClick = copyClickCandidate{} | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or | |
| 143 | + | // teardown occurring while publication revalidates can invalidate the transfer. | |
| 144 | + | func (rt *overlayRuntime) clearCopyPointerForTransferLocked() { | |
| 145 | + | rt.copyPointer = copyPointerState{} | |
| 146 | + | rt.copyClick = copyClickCandidate{} | |
| 147 | + | } | |
| 148 | + | ||
| 110 | 149 | func (rt *overlayRuntime) clearCopyModeLocked() { | |
| 111 | 150 | rt.copyMode = nil | |
| 112 | 151 | rt.copyCandidate = nil | |
| @@ -114,6 +153,7 @@ func (rt *overlayRuntime) clearCopyModeLocked() { | |||
| 114 | 153 | rt.copyPane = nil | |
| 115 | 154 | rt.copySearch = nil | |
| 116 | 155 | rt.copySearchPending = nil | |
| 156 | + | rt.invalidateCopyPointerLocked(true) | |
| 117 | 157 | } | |
| 118 | 158 | ||
| 119 | 159 | func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |
| @@ -122,11 +162,14 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |||
| 122 | 162 | } | |
| 123 | 163 | rt.copyMu.Lock() | |
| 124 | 164 | defer rt.copyMu.Unlock() | |
| 125 | - | if rt.copyPane != p || (rt.copyMode == nil && rt.copyCandidate == nil) { | |
| 126 | - | return false | |
| 165 | + | active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil) | |
| 166 | + | prePublication := rt.copyPointer.pane == p || rt.copyClick.pane == p | |
| 167 | + | if active { | |
| 168 | + | rt.clearCopyModeLocked() | |
| 169 | + | } else if prePublication { | |
| 170 | + | rt.invalidateCopyPointerLocked(true) | |
| 127 | 171 | } | |
| 128 | - | rt.clearCopyModeLocked() | |
| 129 | - | return true | |
| 172 | + | return active || prePublication | |
| 130 | 173 | } | |
| 131 | 174 | ||
| 132 | 175 | func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool { | |
| @@ -2,6 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "sync" | 4 | "sync" | ||
| 5 | + | "time" | |||
| 5 | 6 | ||||
| 6 | scopy "github.com/bnema/vev/internal/usecase/copy" | 7 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 7 | "github.com/bnema/vev/internal/usecase/palette" | 8 | "github.com/bnema/vev/internal/usecase/palette" | ||
| @@ -33,22 +34,38 @@ type overlayRuntime struct { | |||||
| 33 | promptSubmit func(string) error | 34 | promptSubmit func(string) error | ||
| 34 | promptPending []byte | 35 | promptPending []byte | ||
| 35 | 36 | ||||
| 36 | - | copyMu sync.Mutex | 37 | + | copyMu sync.Mutex |
| 37 | - | copyMode *scopy.Mode | 38 | + | copyMode *scopy.Mode |
| 38 | - | copyCandidate *scopy.Mode | 39 | + | copyCandidate *scopy.Mode |
| 39 | - | copyDocument *scopy.Document | 40 | + | copyDocument *scopy.Document |
| 40 | - | copyPane *pane | 41 | + | copyPane *pane |
| 41 | - | copyPending []byte | 42 | + | copyPending []byte |
| 42 | - | copyESC pendingByteTimer | 43 | + | copyESC pendingByteTimer |
| 43 | - | copySearch *visualsearch.Model | 44 | + | copySearch *visualsearch.Model |
| 44 | - | copySearchPending []byte | 45 | + | copySearchPending []byte |
| 45 | - | copyFeedback string | 46 | + | copyFeedback string |
| 46 | - | copyPressRow int | 47 | + | copyPointer copyPointerState |
| 47 | - | copyPressRowValid bool | 48 | + | copyClick copyClickCandidate |
| 48 | - | copyDragging bool | 49 | + | copyPointerEpoch uint64 |
| 49 | - | normalMousePressRow int | 50 | + | } |
| 50 | - | normalMousePressTop int | 51 | + | |
| 51 | - | normalMousePressValid bool | 52 | + | type copyPointerState struct { |
| 53 | + | valid bool | |||
| 54 | + | epoch uint64 | |||
| 55 | + | pane *pane | |||
| 56 | + | document *scopy.Document | |||
| 57 | + | geometry copyMouseGeometry | |||
| 58 | + | press scopy.Pos | |||
| 59 | + | dragging bool | |||
| 60 | + | wordDrag bool | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | type copyClickCandidate struct { | |||
| 64 | + | valid bool | |||
| 65 | + | pane *pane | |||
| 66 | + | pos scopy.Pos | |||
| 67 | + | at time.Time | |||
| 68 | + | dragged bool | |||
| 52 | } | 69 | } | ||
| 53 | 70 | ||||
| 54 | func newOverlayRuntime(ac *attachedClient) *overlayRuntime { | 71 | func newOverlayRuntime(ac *attachedClient) *overlayRuntime { | ||
| @@ -107,6 +124,28 @@ func (rt *overlayRuntime) copySearchActive() bool { | |||||
| 107 | return rt.copySearch != nil | 124 | return rt.copySearch != nil | ||
| 108 | } | 125 | } | ||
| 109 | 126 | ||||
| 127 | + | func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) { | |||
| 128 | + | rt.copyPointerEpoch++ | |||
| 129 | + | pointer.epoch = rt.copyPointerEpoch | |||
| 130 | + | pointer.valid = true | |||
| 131 | + | rt.copyPointer = pointer | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) { | |||
| 135 | + | rt.copyPointerEpoch++ | |||
| 136 | + | rt.copyPointer = copyPointerState{} | |||
| 137 | + | if clearClick { | |||
| 138 | + | rt.copyClick = copyClickCandidate{} | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or | |||
| 143 | + | // teardown occurring while publication revalidates can invalidate the transfer. | |||
| 144 | + | func (rt *overlayRuntime) clearCopyPointerForTransferLocked() { | |||
| 145 | + | rt.copyPointer = copyPointerState{} | |||
| 146 | + | rt.copyClick = copyClickCandidate{} | |||
| 147 | + | } | |||
| 148 | + | ||||
| 110 | func (rt *overlayRuntime) clearCopyModeLocked() { | 149 | func (rt *overlayRuntime) clearCopyModeLocked() { | ||
| 111 | rt.copyMode = nil | 150 | rt.copyMode = nil | ||
| 112 | rt.copyCandidate = nil | 151 | rt.copyCandidate = nil | ||
| @@ -114,6 +153,7 @@ func (rt *overlayRuntime) clearCopyModeLocked() { | |||||
| 114 | rt.copyPane = nil | 153 | rt.copyPane = nil | ||
| 115 | rt.copySearch = nil | 154 | rt.copySearch = nil | ||
| 116 | rt.copySearchPending = nil | 155 | rt.copySearchPending = nil | ||
| 156 | + | rt.invalidateCopyPointerLocked(true) | |||
| 117 | } | 157 | } | ||
| 118 | 158 | ||||
| 119 | func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | 159 | func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | ||
| @@ -122,11 +162,14 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |||||
| 122 | } | 162 | } | ||
| 123 | rt.copyMu.Lock() | 163 | rt.copyMu.Lock() | ||
| 124 | defer rt.copyMu.Unlock() | 164 | defer rt.copyMu.Unlock() | ||
| 125 | - | if rt.copyPane != p || (rt.copyMode == nil && rt.copyCandidate == nil) { | 165 | + | active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil) |
| 126 | - | return false | 166 | + | prePublication := rt.copyPointer.pane == p || rt.copyClick.pane == p |
| 167 | + | if active { | |||
| 168 | + | rt.clearCopyModeLocked() | |||
| 169 | + | } else if prePublication { | |||
| 170 | + | rt.invalidateCopyPointerLocked(true) | |||
| 127 | } | 171 | } | ||
| 128 | - | rt.clearCopyModeLocked() | 172 | + | return active || prePublication |
| 129 | - | return true | |||
| 130 | } | 173 | } | ||
| 131 | 174 | ||||
| 132 | func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool { | 175 | func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool { | ||
M internal/usecase/daemon/overlay_runtime_test.go +23 -0
| @@ -171,6 +171,29 @@ func TestOverlayRuntimeSnapshotCarriesCopyModeDocumentAndClearReleasesIt(t *test | |||
| 171 | 171 | ac.overlays.copyMu.Unlock() | |
| 172 | 172 | } | |
| 173 | 173 | ||
| 174 | + | func TestOverlayRuntimePointerEpochInvalidatesTransferAndPaneClose(t *testing.T) { | |
| 175 | + | ac := &attachedClient{} | |
| 176 | + | ac.initOverlays() | |
| 177 | + | p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1}) | |
| 178 | + | document := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 1), domain.DefaultWordSeparators) | |
| 179 | + | ||
| 180 | + | ac.overlays.copyMu.Lock() | |
| 181 | + | ac.overlays.beginCopyPointerLocked(copyPointerState{pane: p, document: document, press: scopy.Pos{}}) | |
| 182 | + | staleEpoch := ac.overlays.copyPointer.epoch | |
| 183 | + | ac.overlays.clearCopyPointerForTransferLocked() | |
| 184 | + | ac.overlays.invalidateCopyPointerLocked(true) // release/close while publish revalidates | |
| 185 | + | require.NotEqual(t, staleEpoch, ac.overlays.copyPointerEpoch) | |
| 186 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 187 | + | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: p} | |
| 188 | + | ac.overlays.copyMu.Unlock() | |
| 189 | + | ||
| 190 | + | require.True(t, ac.overlays.clearCopyModeForPane(p)) | |
| 191 | + | ac.overlays.copyMu.Lock() | |
| 192 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 193 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 194 | + | ac.overlays.copyMu.Unlock() | |
| 195 | + | } | |
| 196 | + | ||
| 174 | 197 | func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) { | |
| 175 | 198 | ac := &attachedClient{} | |
| 176 | 199 | ac.initOverlays() | |
| @@ -171,6 +171,29 @@ func TestOverlayRuntimeSnapshotCarriesCopyModeDocumentAndClearReleasesIt(t *test | |||||
| 171 | ac.overlays.copyMu.Unlock() | 171 | ac.overlays.copyMu.Unlock() | ||
| 172 | } | 172 | } | ||
| 173 | 173 | ||||
| 174 | + | func TestOverlayRuntimePointerEpochInvalidatesTransferAndPaneClose(t *testing.T) { | |||
| 175 | + | ac := &attachedClient{} | |||
| 176 | + | ac.initOverlays() | |||
| 177 | + | p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1}) | |||
| 178 | + | document := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 1), domain.DefaultWordSeparators) | |||
| 179 | + | ||||
| 180 | + | ac.overlays.copyMu.Lock() | |||
| 181 | + | ac.overlays.beginCopyPointerLocked(copyPointerState{pane: p, document: document, press: scopy.Pos{}}) | |||
| 182 | + | staleEpoch := ac.overlays.copyPointer.epoch | |||
| 183 | + | ac.overlays.clearCopyPointerForTransferLocked() | |||
| 184 | + | ac.overlays.invalidateCopyPointerLocked(true) // release/close while publish revalidates | |||
| 185 | + | require.NotEqual(t, staleEpoch, ac.overlays.copyPointerEpoch) | |||
| 186 | + | require.False(t, ac.overlays.copyPointer.valid) | |||
| 187 | + | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: p} | |||
| 188 | + | ac.overlays.copyMu.Unlock() | |||
| 189 | + | ||||
| 190 | + | require.True(t, ac.overlays.clearCopyModeForPane(p)) | |||
| 191 | + | ac.overlays.copyMu.Lock() | |||
| 192 | + | require.False(t, ac.overlays.copyPointer.valid) | |||
| 193 | + | require.False(t, ac.overlays.copyClick.valid) | |||
| 194 | + | ac.overlays.copyMu.Unlock() | |||
| 195 | + | } | |||
| 196 | + | ||||
| 174 | func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) { | 197 | func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) { | ||
| 175 | ac := &attachedClient{} | 198 | ac := &attachedClient{} | ||
| 176 | ac.initOverlays() | 199 | ac.initOverlays() | ||