package tui import ( "strings" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "github.com/smallnest/pigo/internal/cli/ui" ) // This file implements mouse text selection over the rendered shell. Because the // model paints the whole screen as one string (transcript + menu + input + // status), a selection is expressed in screen cells (0-based, top-left origin) // and spans any region uniformly — dragging across transcript output or the // input line both work. The left mouse button starts a selection at the press // cell and extends it on drag; the selection persists after release so Ctrl+C // can copy it. A plain click (no drag) leaves an empty selection, which clears // any prior highlight and lets Ctrl+C fall back to its interrupt/quit role. // maxCol is a sentinel column that reaches past the end of any rendered row, so // a multi-row selection's interior rows select through to their line end. const maxCol = 1 << 30 // point is a screen cell: x is the column, y the row, both 0-based from the // top-left of the rendered shell. type point struct{ x, y int } // selection is an in-progress or completed text selection. anchor is where the // drag began and cursor is the latest drag point; active is set between the // initial press and the next fresh press. It is a plain value so the Model can // hold and copy it cheaply. type selection struct { active bool anchor point cursor point } // empty reports whether the selection covers no cells — either inactive or a // bare click where the cursor never moved off the anchor. Ctrl+C treats an empty // selection as "nothing to copy" and keeps its interrupt/quit behavior. func (s selection) empty() bool { return !s.active || s.anchor == s.cursor } // ordered returns the selection endpoints in reading order (top-to-bottom, and // left-to-right within a row), so callers can walk rows start.y..end.y without // re-checking which of anchor/cursor came first. func (s selection) ordered() (start, end point) { a, c := s.anchor, s.cursor if a.y > c.y || (a.y == c.y && a.x > c.x) { return c, a } return a, c } // rowRange computes the selected column span [c0, c1) on screen row y for a // selection running start..end. Interior rows of a multi-row selection run from // column 0 through the line end (maxCol); the first row starts at start.x and // the last ends at end.x. ok is false when y falls outside the selection. func rowRange(start, end point, y int) (c0, c1 int, ok bool) { if y < start.y || y > end.y { return 0, 0, false } c0, c1 = 0, maxCol if y == start.y { c0 = start.x } if y == end.y { c1 = end.x } if c0 < 0 { c0 = 0 } if c1 < c0 { c1 = c0 } return c0, c1, true } // selectRow walks one rendered row (ANSI stripped to plain cells) and returns // both the row with the selected span visually highlighted and the selected // text itself. Columns are measured in display cells (ui.Width) so double-width // runes are never split. The highlight is applied over plain text — the row's // original coloring is dropped on the intersected row while a selection is live, // which keeps the overlay ANSI-safe without parsing the embedded escapes. func selectRow(row string, c0, c1 int, hi lipgloss.Style) (highlighted, text string) { plain := ansi.Strip(row) var out, sel, run strings.Builder flush := func() { if run.Len() > 0 { out.WriteString(hi.Render(run.String())) run.Reset() } } col := 0 for _, r := range plain { w := ui.Width(string(r)) if col >= c0 && col < c1 { run.WriteRune(r) sel.WriteRune(r) } else { flush() out.WriteRune(r) } col += w } flush() return out.String(), sel.String() }