feat(tui-selection): drag-select highlight + OSC 52 auto-copy (#2532, presentation wave Part 3, red-first)
A left-press inside a text pane (chat/tasks) starts a drag; motion with the button held extends the head clamped to the pane's rect; the release extracts exactly the highlighted WORDS (ANSI-stripped, newlines between rows) and copies via OSC 52 — DIRECT escape emission (belt-terminated, base64 payload; the cell parser drops OSC so the sequence can never ride the view), capability-detected on the OSC 8 denylist law (known non-carriers + RUNE_NO_OSC52 degrade to highlight-only, honestly). The highlight is a shell-owned display overlay (reverse video at View time, display-column faithful); chrome that owns the mouse (picker, palette, cards) never starts a drag; a collapsed drag (plain click) copies nothing. All-motion mouse enabled for the drag leg.
This commit is contained in:
1 parent
54bc75dcb3
commit
1ea093ad18
4 files changed
+484
-16
No files matched your search
+13
-13
@@ -4,19 +4,19 @@
|
||||
// answer (TUI-side, after the turn — zero model-side cost, B1) and
|
||||
// each block type takes its presentation (B2):
|
||||
//
|
||||
// 1. Speech (prose) — a bubble that GROWS with content; consecutive
|
||||
// prose joins ONE bubble; left-aligned; the outline carries the
|
||||
// SPEAKER'S colour (owner ruling #5).
|
||||
// 2. Table — BREAKS OUT of the bubble: a full-pane-width bordered
|
||||
// table, header emphasised, border in the speaker's colour.
|
||||
// 3. Bullet lists / numbered steps — own presentation OUTSIDE the
|
||||
// bubble: a left rail in the speaker's colour, hanging indent,
|
||||
// steps numbered, genuine bullets dotted. NEVER per-sentence
|
||||
// bullets (the D47 defect).
|
||||
// 4. Code fences — route to the tasks pane work stream (D47/D44
|
||||
// law, already contracted; the chat keeps the D36 collapsed
|
||||
// summary via the rich-bubble path).
|
||||
// 5. Headings — full-width, speaker-coloured underline, no bubble.
|
||||
// 1. Speech (prose) — a bubble that GROWS with content; consecutive
|
||||
// prose joins ONE bubble; left-aligned; the outline carries the
|
||||
// SPEAKER'S colour (owner ruling #5).
|
||||
// 2. Table — BREAKS OUT of the bubble: a full-pane-width bordered
|
||||
// table, header emphasised, border in the speaker's colour.
|
||||
// 3. Bullet lists / numbered steps — own presentation OUTSIDE the
|
||||
// bubble: a left rail in the speaker's colour, hanging indent,
|
||||
// steps numbered, genuine bullets dotted. NEVER per-sentence
|
||||
// bullets (the D47 defect).
|
||||
// 4. Code fences — route to the tasks pane work stream (D47/D44
|
||||
// law, already contracted; the chat keeps the D36 collapsed
|
||||
// summary via the rich-bubble path).
|
||||
// 5. Headings — full-width, speaker-coloured underline, no bubble.
|
||||
//
|
||||
// B4: a long answer never breaks the bubble — the growing-bubble
|
||||
// model replaces the fixed-shape card; answers stay left-aligned.
|
||||
|
||||
@@ -276,6 +276,11 @@ type model struct {
|
||||
consentHub *consent.Hub
|
||||
consentCard *consent.Card
|
||||
consentBusy bool
|
||||
|
||||
// THE SELECTION LAYER (#2532): the drag-selection's display state
|
||||
// (selection.go) — anchor/head/pane. Shell-owned display state,
|
||||
// never face state.
|
||||
sel selState
|
||||
}
|
||||
|
||||
func initialModel() model {
|
||||
@@ -2434,6 +2439,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.applyApprovalReceipt(msg)
|
||||
return m, nil
|
||||
case tea.MouseClickMsg:
|
||||
// THE SELECTION LAYER (#2532): a left press inside a text
|
||||
// pane starts a drag selection (the release copies via OSC 52
|
||||
// when the drag moved); chrome that owns the mouse (picker,
|
||||
// palette, cards) never starts one. The face's own click
|
||||
// routing still runs — a press that never moves is a click.
|
||||
if msg.Button == tea.MouseLeft && !m.chromeOwnsMouse() {
|
||||
m.selStart(msg.X, msg.Y, m.face.Render().Regions)
|
||||
}
|
||||
// the T5 card's mouse leg: the y/n buttons' regions resolve
|
||||
// FIRST (the card is a decision boundary), then the face
|
||||
// chain's own regions — the shell adds no other click
|
||||
@@ -2491,6 +2504,24 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.face.Click(msg.X, msg.Y)
|
||||
m.syncFocusSignage(focusBefore)
|
||||
return m, nil
|
||||
case tea.MouseMotionMsg:
|
||||
// the #2532 drag-select's motion leg: while the left button
|
||||
// holds and the drag started in a text pane, the head follows
|
||||
// the pointer (clamped to the pane). Motion alone never routes
|
||||
// the face.
|
||||
if msg.Button == tea.MouseLeft && m.sel.inFlight() {
|
||||
m.selMove(msg.X, msg.Y, m.face.Render().Regions)
|
||||
}
|
||||
return m, nil
|
||||
case tea.MouseReleaseMsg:
|
||||
// the #2532 drag-select's release leg: the highlight's text
|
||||
// copies via OSC 52 (capability-gated, direct emission) and
|
||||
// the highlight clears. A collapsed drag (a plain click)
|
||||
// copies nothing.
|
||||
if msg.Button == tea.MouseLeft && m.sel.inFlight() {
|
||||
m.selFinish(m.face.Render().Content, os.Stdout)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -2504,6 +2535,9 @@ func (m model) View() tea.View {
|
||||
if m.face.ActivityWindowOpen() {
|
||||
content = m.overlayActivityWindow(content)
|
||||
}
|
||||
// the #2532 drag highlight overlays the frame while the drag is
|
||||
// in flight (selection.go — shell display state, never a pane).
|
||||
content = m.overlaySelection(content)
|
||||
var b strings.Builder
|
||||
b.WriteString(content)
|
||||
b.WriteString("\n")
|
||||
@@ -2520,10 +2554,11 @@ func (m model) View() tea.View {
|
||||
// must DISAPPEAR — the terminal restores the pre-boot command line,
|
||||
// never a ghost of the last frame). Alt-screen draws the face on
|
||||
// the terminal's separate screen buffer; quitting restores the
|
||||
// main screen exactly as it was. Mouse cell-motion stays on for
|
||||
// the selection law's click leg (D29/TD-4).
|
||||
// main screen exactly as it was. ALL-motion mouse stays on: the
|
||||
// selection law's click leg (D29/TD-4) and the #2532 drag-select
|
||||
// (motion events while the button holds).
|
||||
v.AltScreen = true
|
||||
v.MouseMode = tea.MouseModeCellMotion
|
||||
v.MouseMode = tea.MouseModeAllMotion
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -3272,6 +3307,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
wireOSCDetection() // the version hyperlink's real-terminal detection
|
||||
wireOSC52Detection() // the selection copy's real-terminal detection (#2532)
|
||||
bootCheck = update.DefaultCheck
|
||||
// D38: the ledger section reads the task markdown ledger through
|
||||
// the library seam — the same directory the serve surface
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// selection.go — THE SELECTION + AUTO-COPY layer (#2532, the
|
||||
// presentation wave Part 3): mouse-drag highlights text per pane and
|
||||
// the release copies it via OSC 52 — capability-detected, DIRECT
|
||||
// escape emission (the same law as the OSC 8 denylist gate: a known
|
||||
// non-carrier degrades honestly, the selection still highlights, the
|
||||
// copy never fabricates).
|
||||
//
|
||||
// Layer law: this is the SHELL's own display state — the drag's
|
||||
// anchor/head, the pane it started in, the last copy. The face chain
|
||||
// never knows; the highlight is an overlay applied to the rendered
|
||||
// frame at View time, never a pane concern.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"rune.dev/rune/tui/semantic"
|
||||
)
|
||||
|
||||
// selState is one drag-selection's display state.
|
||||
type selState struct {
|
||||
active bool
|
||||
anchorX, head int // head X
|
||||
anchorY, headY int
|
||||
pane string // the starting region's ID ("region.chat" | "region.tasks")
|
||||
}
|
||||
|
||||
// selInFlight reports whether a drag is highlighting right now.
|
||||
func (s *selState) inFlight() bool { return s.active }
|
||||
|
||||
// osc52Allowed gates the OSC 52 copy (detect-then-emit): known
|
||||
// non-carriers and the explicit opt-out degrade to highlight-only.
|
||||
// Hermetic tests stay false by construction (the default); the real
|
||||
// main() wires the detection in — exactly the linkVersions law.
|
||||
var osc52Allowed = func() bool { return false }
|
||||
|
||||
// osc52Emit writes the OSC 52 copy sequence for text — DIRECT escape
|
||||
// emission: the sequence must reach the terminal byte-intact, so it
|
||||
// never rides the view (the cell parser drops OSC). Belt-terminated
|
||||
// (\x07): the most widely honored terminator.
|
||||
func osc52Emit(out io.Writer, text string) {
|
||||
if !osc52Allowed() || text == "" {
|
||||
return
|
||||
}
|
||||
enc := base64.StdEncoding.EncodeToString([]byte(text))
|
||||
_, _ = io.WriteString(out, "\x1b]52;c;"+enc+"\x07")
|
||||
}
|
||||
|
||||
// selStart begins a drag at (x,y) when the cell sits inside a
|
||||
// selectable text pane (the chat or tasks region) and no chrome
|
||||
// surface owns the mouse; it reports whether a selection started.
|
||||
func (m *model) selStart(x, y int, regions []semantic.Region) bool {
|
||||
m.sel = selState{}
|
||||
if m.chromeOwnsMouse() {
|
||||
return false
|
||||
}
|
||||
for _, r := range regions {
|
||||
if r.ID != "region.chat" && r.ID != "region.tasks" {
|
||||
continue
|
||||
}
|
||||
if x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H {
|
||||
m.sel = selState{active: true, pane: r.ID,
|
||||
anchorX: x, head: x, anchorY: y, headY: y}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// selMove extends the drag's head to (x,y), clamped to the starting
|
||||
// pane's rectangle — a selection never bleeds across panes.
|
||||
func (m *model) selMove(x, y int, regions []semantic.Region) {
|
||||
if !m.sel.active {
|
||||
return
|
||||
}
|
||||
for _, r := range regions {
|
||||
if r.ID != m.sel.pane {
|
||||
continue
|
||||
}
|
||||
m.sel.head = min(max(x, r.X), r.X+r.W-1)
|
||||
m.sel.headY = min(max(y, r.Y), r.Y+r.H-1)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// selFinish ends the drag, extracts the highlighted text from the
|
||||
// frame's content, copies it via OSC 52, and clears the highlight.
|
||||
// Returns the copied text ("" when the drag collapsed to nothing).
|
||||
func (m *model) selFinish(content string, out io.Writer) string {
|
||||
if !m.sel.active {
|
||||
return ""
|
||||
}
|
||||
text := m.selText(content)
|
||||
m.sel = selState{}
|
||||
if text != "" {
|
||||
osc52Emit(out, text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// selText extracts the selected text: the display columns of the
|
||||
// selected rows, ANSI-stripped (the copy is the WORDS the operator
|
||||
// saw), rows joined with newlines. A single-row drag takes its
|
||||
// column span; a multi-row drag takes the anchor's row tail, the
|
||||
// whole middle rows, and the head row's prefix.
|
||||
func (m *model) selText(content string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
y0, y1 := m.sel.anchorY, m.sel.headY
|
||||
x0, x1 := m.sel.anchorX, m.sel.head
|
||||
if y0 > y1 || (y0 == y1 && x0 > x1) {
|
||||
y0, y1 = y1, y0
|
||||
x0, x1 = x1, x0
|
||||
}
|
||||
var rows []string
|
||||
for y := y0; y <= y1 && y < len(lines); y++ {
|
||||
r := []rune(semantic.StripANSI(lines[y]))
|
||||
lo, hi := 0, len(r)
|
||||
if y == y0 && y0 != y1 {
|
||||
lo = min(x0, len(r))
|
||||
} else if y == y0 && y0 == y1 {
|
||||
lo, hi = min(x0, len(r)), min(x1+1, len(r))
|
||||
} else if y == y1 {
|
||||
hi = min(x1+1, len(r))
|
||||
}
|
||||
if lo > hi {
|
||||
lo = hi
|
||||
}
|
||||
rows = append(rows, strings.TrimRight(string(r[lo:hi]), " "))
|
||||
}
|
||||
return strings.Join(rows, "\n")
|
||||
}
|
||||
|
||||
// overlaySelection paints the drag's highlight over the frame
|
||||
// content: the selected cells render in reverse video — an overlay,
|
||||
// never a pane concern. Applied only while the drag is in flight.
|
||||
func (m *model) overlaySelection(content string) string {
|
||||
if !m.sel.active {
|
||||
return content
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
y0, y1 := m.sel.anchorY, m.sel.headY
|
||||
x0, x1 := m.sel.anchorX, m.sel.head
|
||||
if y0 > y1 || (y0 == y1 && x0 > x1) {
|
||||
y0, y1 = y1, y0
|
||||
x0, x1 = x1, x0
|
||||
}
|
||||
for y := y0; y <= y1 && y < len(lines); y++ {
|
||||
lo, hi := 0, widthANSIOf(lines[y])
|
||||
if y == y0 && y0 != y1 {
|
||||
lo = min(x0, hi)
|
||||
} else if y == y0 && y0 == y1 {
|
||||
lo, hi = min(x0, hi), min(x1+1, hi)
|
||||
} else if y == y1 {
|
||||
hi = min(x1+1, hi)
|
||||
}
|
||||
if lo >= hi {
|
||||
continue
|
||||
}
|
||||
lines[y] = spliceReverse(lines[y], lo, hi)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// spliceReverse wraps the display columns [lo,hi) of a styled line in
|
||||
// reverse video, keeping every ANSI run intact on both sides.
|
||||
func spliceReverse(line string, lo, hi int) string {
|
||||
var b strings.Builder
|
||||
col := 0
|
||||
inEsc := false
|
||||
var esc strings.Builder
|
||||
for _, r := range line {
|
||||
if r == '\x1b' {
|
||||
inEsc = true
|
||||
esc.Reset()
|
||||
esc.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if inEsc {
|
||||
esc.WriteRune(r)
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||||
b.WriteString(esc.String())
|
||||
inEsc = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if col == lo {
|
||||
b.WriteString("\x1b[7m")
|
||||
}
|
||||
b.WriteRune(r)
|
||||
col++
|
||||
if col == hi {
|
||||
b.WriteString("\x1b[27m")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// widthANSIOf measures one line's display columns (escapes carry none).
|
||||
func widthANSIOf(s string) int { return len([]rune(semantic.StripANSI(s))) }
|
||||
|
||||
// chromeOwnsMouse reports whether a chrome surface (the centered
|
||||
// picker, the palette, the model menu, a decision card) owns the
|
||||
// mouse right now — a drag selection never starts under them.
|
||||
func (m *model) chromeOwnsMouse() bool {
|
||||
return m.pickerOpen || m.paletteOpen() || m.menuOpen ||
|
||||
m.apprv != nil || m.consentCard != nil || m.gaugesOpen
|
||||
}
|
||||
|
||||
// wireOSC52Detection installs the real terminal detection for the
|
||||
// selection copy (#2532, detect-then-emit — the OSC 8 denylist law
|
||||
// carried): OSC 52 is near-universal on modern terminals (Konsole,
|
||||
// VTE, kitty, alacritty, foot, WezTerm, iTerm, tmux all honor it,
|
||||
// some behind an allow-list setting), so the gate DENYLISTS the known
|
||||
// non-carriers and copies everywhere else; a non-carrier degrades to
|
||||
// highlight-only (the drag still shows; the copy never fabricates).
|
||||
// RUNE_NO_OSC52 opts out explicitly.
|
||||
func wireOSC52Detection() {
|
||||
osc52Allowed = func() bool {
|
||||
if os.Getenv("RUNE_NO_OSC52") != "" {
|
||||
return false
|
||||
}
|
||||
switch t := os.Getenv("TERM"); t {
|
||||
case "dumb", "linux", "vt100", "vt220", "xterm-mono", "cons25", "emacs":
|
||||
return false
|
||||
}
|
||||
if os.Getenv("INSIDE_EMACS") != "" || os.Getenv("EMACS") != "" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// selection_test.go — THE SELECTION + AUTO-COPY suite (#2532, the
|
||||
// presentation wave Part 3): a left-press inside a text pane starts a
|
||||
// drag; motion extends the head clamped to the pane; the release
|
||||
// extracts the highlighted WORDS (ANSI-stripped) and copies via OSC 52
|
||||
// (capability-gated, direct escape emission, base64 payload); a plain
|
||||
// click copies nothing; chrome never starts a drag.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/bubbletea/v2"
|
||||
|
||||
"rune.dev/rune/tui/chatpane"
|
||||
"rune.dev/rune/tui/semantic"
|
||||
)
|
||||
|
||||
func selModel(t *testing.T) model {
|
||||
t.Helper()
|
||||
m := sized(t, initialModel(), 80, 24)
|
||||
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleUser,
|
||||
Content: "the quick brown fox", Ts: "2026-10-22T10:00:00Z"})
|
||||
return m
|
||||
}
|
||||
|
||||
func chatRegionOf(t *testing.T, m model) semantic.Region {
|
||||
t.Helper()
|
||||
for _, r := range m.face.Render().Regions {
|
||||
if r.ID == "region.chat" {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatal("no region.chat")
|
||||
return semantic.Region{}
|
||||
}
|
||||
|
||||
func TestDragStartsInTextPaneOnly(t *testing.T) {
|
||||
m := selModel(t)
|
||||
reg := chatRegionOf(t, m)
|
||||
if !m.selStart(reg.X+2, reg.Y+3, m.face.Render().Regions) {
|
||||
t.Fatal("a press inside the chat pane must start a selection")
|
||||
}
|
||||
if !m.sel.inFlight() || m.sel.pane != "region.chat" {
|
||||
t.Fatalf("selection state wrong: %+v", m.sel)
|
||||
}
|
||||
// a press outside both text panes starts nothing
|
||||
if m.selStart(reg.X+reg.W+50, reg.Y, m.face.Render().Regions) {
|
||||
t.Fatal("a press outside the text panes must not select")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDragMotionClampsToPane(t *testing.T) {
|
||||
m := selModel(t)
|
||||
reg := chatRegionOf(t, m)
|
||||
m.selStart(reg.X, reg.Y+1, m.face.Render().Regions)
|
||||
m.selMove(reg.X+reg.W+30, reg.Y-8, m.face.Render().Regions)
|
||||
if m.sel.head != reg.X+reg.W-1 || m.sel.headY != reg.Y {
|
||||
t.Fatalf("the drag head must clamp to the pane rect: %+v (pane %+v)", m.sel, reg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseExtractsHighlightedWordsAndCopies(t *testing.T) {
|
||||
allowed := false
|
||||
osc52Allowed = func() bool { return allowed }
|
||||
defer func() { osc52Allowed = func() bool { return false } }()
|
||||
|
||||
m := selModel(t)
|
||||
_ = chatRegionOf(t, m)
|
||||
frame := m.face.Render().Content
|
||||
// find the row carrying the phrase and the phrase's exact column
|
||||
rows := strings.Split(semantic.StripANSI(frame), "\n")
|
||||
row, col := -1, -1
|
||||
for i, r := range rows {
|
||||
if j := strings.Index(r, "the quick brown fox"); j >= 0 {
|
||||
row, col = i, len([]rune(r[:j]))
|
||||
break
|
||||
}
|
||||
}
|
||||
if row < 0 {
|
||||
t.Fatalf("the phrase must render:\n%s", frame)
|
||||
}
|
||||
m.selStart(col, row, m.face.Render().Regions)
|
||||
m.selMove(col+len("the quick brown")-1, row, m.face.Render().Regions)
|
||||
|
||||
var sb strings.Builder
|
||||
text := m.selFinish(frame, &fakeOut{&sb})
|
||||
if text != "the quick brown" {
|
||||
t.Fatalf("the release must extract exactly the highlighted words, got %q", text)
|
||||
}
|
||||
if m.sel.inFlight() {
|
||||
t.Fatal("the release must clear the highlight")
|
||||
}
|
||||
// the OSC 52 emission (gated): the exact sequence, base64 payload
|
||||
allowed = true
|
||||
m2 := selModel(t)
|
||||
m2.selStart(col, row, m2.face.Render().Regions)
|
||||
m2.selMove(col+len("the quick")-1, row, m2.face.Render().Regions)
|
||||
sb.Reset()
|
||||
m2.selFinish(m2.face.Render().Content, &fakeOut{&sb})
|
||||
out := sb.String()
|
||||
if !strings.HasPrefix(out, "\x1b]52;c;") || !strings.HasSuffix(out, "\x07") {
|
||||
t.Fatalf("the copy must be a direct OSC 52 emission, got %q", out)
|
||||
}
|
||||
if want := "dGhlIHF1aWNr"; !strings.Contains(out, want) { // base64("the quick")
|
||||
t.Fatalf("the OSC 52 payload must carry base64(\"the quick\")=dGhlIHF1aWNr, got %q", out)
|
||||
}
|
||||
// gate off: no bytes
|
||||
allowed = false
|
||||
m3 := selModel(t)
|
||||
m3.selStart(col, row, m3.face.Render().Regions)
|
||||
m3.selMove(col+len("the quick")-1, row, m3.face.Render().Regions)
|
||||
sb.Reset()
|
||||
m3.selFinish(m3.face.Render().Content, &fakeOut{&sb})
|
||||
if sb.String() != "" {
|
||||
t.Fatalf("the gate off must emit nothing, got %q", sb.String())
|
||||
}
|
||||
}
|
||||
|
||||
type fakeOut struct{ b *strings.Builder }
|
||||
|
||||
func (f *fakeOut) WriteString(s string) (int, error) { return f.b.WriteString(s) }
|
||||
func (f *fakeOut) Write(p []byte) (int, error) { return f.b.Write(p) }
|
||||
|
||||
func TestCollapsedDragCopiesNothing(t *testing.T) {
|
||||
m := selModel(t)
|
||||
reg := chatRegionOf(t, m)
|
||||
m.selStart(reg.X+2, reg.Y+2, m.face.Render().Regions)
|
||||
// no motion: a plain click
|
||||
if text := m.selFinish(m.face.Render().Content, os.Stdout); text != "" {
|
||||
t.Fatalf("a collapsed drag (click) must copy nothing, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectionHighlightOverlaysWhileInFlight(t *testing.T) {
|
||||
m := selModel(t)
|
||||
reg := chatRegionOf(t, m)
|
||||
frame := m.face.Render().Content
|
||||
rows := strings.Split(semantic.StripANSI(frame), "\n")
|
||||
row, col := -1, 0
|
||||
for i, r := range rows {
|
||||
if j := strings.Index(r, "the quick brown fox"); j >= 0 {
|
||||
row, col = i, len([]rune(r[:j]))
|
||||
break
|
||||
}
|
||||
}
|
||||
m.selStart(col, row, m.face.Render().Regions)
|
||||
m.selMove(col+len("the quick")-1, row, m.face.Render().Regions)
|
||||
painted := m.overlaySelection(frame)
|
||||
if !strings.Contains(painted, "\x1b[7m") {
|
||||
t.Fatalf("the in-flight drag must paint reverse video over the frame")
|
||||
}
|
||||
// the overlay is display-column faithful: the same text, escapes aside
|
||||
if semantic.StripANSI(painted) != semantic.StripANSI(frame) {
|
||||
t.Fatal("the highlight overlay must never change the frame's text")
|
||||
}
|
||||
_ = reg
|
||||
}
|
||||
|
||||
func TestChromeNeverStartsADrag(t *testing.T) {
|
||||
m := selModel(t)
|
||||
reg := chatRegionOf(t, m)
|
||||
m.pickerOpen = true // the centered picker owns the mouse
|
||||
if m.selStart(reg.X+2, reg.Y+3, m.face.Render().Regions) {
|
||||
t.Fatal("chrome that owns the mouse must never start a drag")
|
||||
}
|
||||
if m.chromeOwnsMouse() != true {
|
||||
t.Fatal("chromeOwnsMouse must report the picker")
|
||||
}
|
||||
m.pickerOpen = false
|
||||
if m.chromeOwnsMouse() {
|
||||
t.Fatal("chromeOwnsMouse must release with the picker closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionRoutesThroughShell(t *testing.T) {
|
||||
m := selModel(t)
|
||||
reg := chatRegionOf(t, m)
|
||||
next, _ := m.Update(tea.MouseClickMsg{X: reg.X + 2, Y: reg.Y + 3,
|
||||
Button: tea.MouseLeft})
|
||||
m = next.(model)
|
||||
if !m.sel.inFlight() {
|
||||
t.Fatal("the click must arm the selection")
|
||||
}
|
||||
next, _ = m.Update(tea.MouseMotionMsg{X: reg.X + 6, Y: reg.Y + 3,
|
||||
Button: tea.MouseLeft})
|
||||
m = next.(model)
|
||||
if m.sel.head != reg.X+6 {
|
||||
t.Fatalf("motion with the button held must extend the head: %+v", m.sel)
|
||||
}
|
||||
next, _ = m.Update(tea.MouseReleaseMsg{X: reg.X + 6, Y: reg.Y + 3,
|
||||
Button: tea.MouseLeft})
|
||||
m = next.(model)
|
||||
if m.sel.inFlight() {
|
||||
t.Fatal("the release must finish the selection")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user