cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / internal / ui / clipboard.go
clipboard.go 8.5K
1package ui23import (4 "strings"56 tea "charm.land/bubbletea/v2"7 "github.com/charmbracelet/x/ansi"89 "src.rest/cat/012/internal/sheet"10)1112// Copy, cut and paste follow Sheets: Ctrl+C copies the selection and13// marks it until Esc or the next edit, Ctrl+V pastes with references14// adjusted and the formatting the cells showed, Ctrl+X then Ctrl+V15// moves. Whole columns or rows (trimmed to their cells by the copy)16// pasted at the top of a column or start of a row carry, or move, their17// column and row formats. The copied values also go to the18// system clipboard as TSV (OSC 52), and a multi-cell paste from the19// terminal fills a block of cells.2021// clipboard is what Ctrl+V pastes.22type clipboard struct {23 clip *sheet.Clip24 sheet *sheet.Sheet // where the clip was copied from25 cut bool // pasting moves the cells rather than copying them26 marked bool // the source shows the copy marker27 keep bool // the last edit was a paste, which keeps the marker28}2930func init() {31 register(32 &command{id: "edit.copy", title: "Copy", desc: "Copy the selection", run: func(m *Model) tea.Cmd { return m.copy(false) }},33 &command{id: "edit.cut", title: "Cut", desc: "Cut the selection, to move it where you paste", edits: (*Model).selection, run: func(m *Model) tea.Cmd { return m.copy(true) }},34 &command{id: "edit.paste", title: "Paste", desc: "Paste into the selection, adjusting references", edits: (*Model).pasteTarget, run: func(m *Model) tea.Cmd { return m.paste(false) }},35 &command{id: "edit.paste_values", title: "Paste values only", desc: "Paste the copied values without formulas", edits: (*Model).pasteTarget, run: func(m *Model) tea.Cmd { return m.paste(true) }},36 )37 keymap["ctrl+c"] = "edit.copy"38 keymap["ctrl+x"] = "edit.cut"39 keymap["ctrl+v"] = "edit.paste"40 keymap["ctrl+shift+v"] = "edit.paste_values"41}4243// copy puts the selection on the clipboard and the system clipboard.44func (m *Model) copy(cut bool) tea.Cmd {45 clip := m.sheet.Copy(m.selection())46 m.copied = clipboard{clip: clip, sheet: m.sheet, cut: cut, marked: true}47 text := clip.TextIn(m.locale())48 if text == nil {49 m.note = "Copied; too many cells for the system clipboard, but Ctrl+V pastes them here"50 return nil51 }52 return tea.SetClipboard(formatTSV(text))53}5455// paste pastes the clipboard into the selection and selects what it56// wrote.57func (m *Model) paste(values bool) tea.Cmd {58 c := m.copied59 if c.clip == nil {60 m.note = "Nothing to paste: copy with Ctrl+C first"61 return nil62 }63 var moved sheet.Rect64 if c.cut && !values && !c.sheet.Live() {65 m.copied = clipboard{}66 m.note = "Nothing to move: the cut cells' sheet was deleted"67 return nil68 }69 write := func() (sheet.Rect, error) {70 if c.cut && !values {71 moved = c.clip.MoveRange(m.cur)72 m.copied = clipboard{}73 return c.sheet.MoveTo(m.sheet, moved, m.cur)74 }75 m.copied.keep = true76 return m.sheet.Paste(c.clip, m.selection(), values)77 }78 // Cells moved take their rules along (sheet.Move, sheet.MoveTo),79 // so what's pasted meets the rules where it lands only when copied.80 check := !c.cut || values81 var r sheet.Rect82 if check {83 var ok bool84 if r, ok = m.writeChecked("Paste", write); !ok {85 return nil86 }87 } else {88 var err error89 if r, err = write(); err != nil {90 m.fail(err.Error())91 return nil92 }93 }94 m.selectRect(r)95 switch {96 case c.cut && !values:97 m.note = "Moved " + c.labelOf(m.sheet, moved) + " to " + r.String()98 case values:99 m.note = "Pasted values into " + countCells(r) + " at " + r.String()100 default:101 m.note = "Pasted " + countCells(r) + " at " + r.String()102 }103 return nil104}105106// marks reports whether a, on the sheet shown, shows the copy marker.107func (c *clipboard) marks(shown *sheet.Sheet, a sheet.Addr) bool {108 return c.marked && c.sheet == shown && c.clip.Range.Contains(a)109}110111// label names the copied range, with its sheet when that isn't the112// one shown: A1:B3, or Sheet1!A1:B3.113func (c *clipboard) label(shown *sheet.Sheet) string { return c.labelOf(shown, c.clip.Range) }114115// labelOf names r on the copied range's sheet.116func (c *clipboard) labelOf(shown *sheet.Sheet, r sheet.Rect) string {117 if c.sheet != shown {118 return sheet.Qualified(c.sheet.Name(), r)119 }120 return r.String()121}122123// clearMark hides the copy marker; a pending cut is cancelled, as in124// Sheets, since the cells it would move may have changed.125func (c *clipboard) clearMark() {126 if c.cut {127 *c = clipboard{}128 }129 c.marked = false130}131132// pasteTextRange is the block pasted text fills from a: as many rows and133// columns as it has, or a alone for a single value.134func pasteTextRange(a sheet.Addr, content string) sheet.Rect {135 content = strings.TrimSuffix(strings.TrimSuffix(content, "\n"), "\r")136 r := sheet.Rect{From: a, To: a}137 if !strings.ContainsAny(content, "\t\n") {138 return r139 }140 rows := parseTSV(content)141 width := 0142 for _, row := range rows {143 width = max(width, len(row))144 }145 r.To = sheet.Addr{Col: min(a.Col+width-1, sheet.MaxCols-1), Row: min(a.Row+len(rows)-1, sheet.MaxRows-1)}146 return r147}148149// pasteText fills a block of cells from pasted text with tabs or line150// breaks, such as cells copied from another spreadsheet, and reports151// whether it did. Each value is entered as if typed.152func (m *Model) pasteText(content string) bool {153 content = strings.TrimSuffix(strings.TrimSuffix(content, "\n"), "\r")154 if !strings.ContainsAny(content, "\t\n") {155 return false156 }157 rows := parseTSV(content)158 width := 0159 for _, row := range rows {160 width = max(width, len(row))161 }162 r := sheet.Rect{From: m.cur, To: sheet.Addr{Col: m.cur.Col + width - 1, Row: m.cur.Row + len(rows) - 1}}163 if width*len(rows) > sheet.MaxCells() {164 m.fail(sheet.ErrFillTooBig.Error())165 return true166 }167 if !r.To.Valid() {168 m.fail(sheet.ErrPasteEdge.Error())169 return true170 }171 if m.refuseEdit(r, false) {172 return true173 }174 _, ok := m.writeChecked("Paste", func() (sheet.Rect, error) {175 return r, m.sheet.Batch(sheet.Change{Label: "paste into " + r.String(), Focus: r}, func() error {176 for i, row := range rows {177 for j := range width {178 var v string179 if j < len(row) {180 v = row[j]181 }182 a := sheet.Addr{Col: r.From.Col + j, Row: r.From.Row + i}183 if m.sheet.Set(a, m.storedEntry(v)) != nil {184 m.sheet.Set(a, "'"+v) // a broken formula stays as text185 }186 }187 }188 return nil189 })190 })191 if !ok {192 return true193 }194 m.selectRect(r)195 m.note = "Pasted " + countCells(r) + " at " + r.String()196 return true197}198199// formatTSV writes rows as tab-separated values the way Sheets puts them200// on the clipboard: fields with tabs, line breaks or a leading quote are201// quoted, with quotes doubled.202func formatTSV(rows [][]string) string {203 var b strings.Builder204 for i, row := range rows {205 if i > 0 {206 b.WriteByte('\n')207 }208 for j, f := range row {209 if j > 0 {210 b.WriteByte('\t')211 }212 if strings.ContainsAny(f, "\t\n\r") || strings.HasPrefix(f, `"`) {213 f = `"` + strings.ReplaceAll(f, `"`, `""`) + `"`214 }215 b.WriteString(f)216 }217 }218 return b.String()219}220221// parseTSV reads tab-separated rows, keeping blank lines and fields, and222// unquoting fields written by formatTSV (or Sheets and Excel).223func parseTSV(s string) [][]string {224 var rows [][]string225 var row []string226 var field strings.Builder227 quoted, fieldStart := false, true228 for i := 0; i < len(s); i++ {229 c := s[i]230 switch {231 case quoted && c == '"' && i+1 < len(s) && s[i+1] == '"':232 field.WriteByte('"')233 i++234 case quoted && c == '"':235 quoted = false236 case quoted:237 field.WriteByte(c)238 case c == '"' && fieldStart:239 quoted = true240 case c == '\t':241 row = append(row, field.String())242 field.Reset()243 fieldStart = true244 continue245 case c == '\n':246 row = append(row, strings.TrimSuffix(field.String(), "\r"))247 rows = append(rows, row)248 row, fieldStart = nil, true249 field.Reset()250 continue251 default:252 field.WriteByte(c)253 }254 fieldStart = false255 }256 return append(rows, append(row, strings.TrimSuffix(field.String(), "\r")))257}258259// readyLine is the context line in READY mode: feedback on the last260// action, or what to do with a copied range.261func (m *Model) readyLine() string {262 switch {263 case m.warn != "":264 return m.th.Warning.Render(m.warn)265 case m.note != "":266 return m.note267 case m.copied.marked && m.copied.cut:268 return "Cut " + m.copied.label(m.sheet) + " " + m.th.KeyHints("Ctrl+V", "move here", "Esc", "cancel")269 case m.copied.marked:270 text := "Copied " + m.copied.label(m.sheet) + " "271 full := text + m.th.KeyHints("Ctrl+V", "paste", "Ctrl+Shift+V", "paste values", "Esc", "clear")272 if ansi.StringWidth(full) <= m.width {273 return full274 }275 return text + m.th.KeyHints("Ctrl+V", "paste", "Esc", "clear")276 }277 return ""278}