cat / 012
clone

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

Carry column and row formats with copy, cut and move, as Sheets does

Dami committed 20aee77
A paste gives each cell the formatting its source showed (cell, row,
column or sheet). Whole columns or rows pasted at the start of a line
carry their line formats; cut and pasted, they move them.
commit
20aee77255e9c7f6e3cd873f169bb5028e3fb185
parent
5f1f93d65828eb7bca4689925caf06c3387ef5fd
author
Dami <dami.etoile@gmail.com>
9 files changed, +604 -54
Me2e/format_test.go15++
Ainternal/sheet/clipfmt.go272++++++++++++++++++++++++++++++
Ainternal/sheet/linecopy_test.go184++++++++++++++++++++
Minternal/sheet/lines.go11+-
Minternal/sheet/move.go11+-
Minternal/sheet/ops.go72+++++---
Minternal/ui/clipboard.go40++---
Minternal/ui/edit.go13+-
Ainternal/ui/linecopy_test.go40++++
M e2e/format_test.go +15 -0
A internal/sheet/clipfmt.go +272 -0
@@ -0,0 +1,272 @@
1+package sheet2+3+import (4+ "maps"5+ "slices"6+)7+8+// Formats travel with copy, cut and move, as in Sheets. A copy takes the9+// formatting its range shows, whatever it comes from (the cell, its row,10+// its column, the sheet), and a paste gives each destination cell what11+// its source showed, storing only what the destination's own lines don't12+// already show. Copying whole columns or rows and pasting them as whole13+// lines (at the top of a column, the start of a row, or A1 for the whole14+// sheet) carries the line formats themselves, and cutting them moves the15+// line formats, leaving the source lines plain.16+17+// clipFormats is the formatting of a copied range, taken at copy time.18+type clipFormats struct {19+ cols, rows bool // whole columns, whole rows were copied (both: the sheet)20+ cells map[Addr]lineFmt // what each stored cell showed, by offset21+ colFmt, rowFmt map[int]lineFmt // formatted columns and rows crossing the range, by offset22+ sheet lineFmt23+}24+25+// copyFormats takes the formatting of r.26+func (s *Sheet) copyFormats(r Rect) clipFormats {27+ f := clipFormats{cols: r.AllRows(), rows: r.AllCols(), cells: map[Addr]lineFmt{}, sheet: s.lines.sheet}28+ for _, a := range s.cellsIn(r) {29+ f.cells[Addr{Col: a.Col - r.From.Col, Row: a.Row - r.From.Row}] = s.effective(a)30+ }31+ f.colFmt = linesIn(s.lines.cols, r.From.Col, r.To.Col)32+ f.rowFmt = linesIn(s.lines.rows, r.From.Row, r.To.Row)33+ return f34+}35+36+// linesIn is the lines of m from lo to hi, by offset from lo.37+func linesIn(m map[int]lineFmt, lo, hi int) map[int]lineFmt {38+ out := map[int]lineFmt{}39+ for n, l := range m {40+ if n >= lo && n <= hi {41+ out[n-lo] = l42+ }43+ }44+ return out45+}46+47+// mode is how the formats land: on cells, or on whole lines.48+func (f *clipFormats) mode() lineMode {49+ switch {50+ case f.cols && f.rows:51+ return lineAll52+ case f.cols:53+ return lineCols54+ case f.rows:55+ return lineRows56+ }57+ return lineBlock58+}59+60+// lined reports whether any line format crosses the copied range.61+func (f *clipFormats) lined() bool {62+ return len(f.colFmt) > 0 || len(f.rowFmt) > 0 || !f.sheet.IsZero()63+}64+65+// want is what the cell at offset off of the copy showed.66+func (f *clipFormats) want(off Addr) lineFmt {67+ if l, ok := f.cells[off]; ok {68+ return l69+ }70+ return fallback(f.rowFmt[off.Row], f.colFmt[off.Col], f.sheet)71+}72+73+// carry is the line format a pasted column or row gets for l, the74+// source's, filling what it leaves to the source sheet's format where75+// the destination sheet's differs.76+func (f *clipFormats) carry(l, dstSheet lineFmt) lineFmt {77+ if l.Format.IsZero() && f.sheet.Format != dstSheet.Format {78+ l.Format = f.sheet.Format79+ }80+ if l.Style.IsZero() && f.sheet.Style != dstSheet.Style {81+ l.Style = f.sheet.Style82+ }83+ return l84+}85+86+// lineMode is whether a paste or move lands on cells or on whole lines.87+type lineMode int88+89+const (90+ lineBlock lineMode = iota91+ lineCols92+ lineRows93+ lineAll94+)95+96+// pasteLayout is where a paste lands: dst, tiled by the clip's size,97+// across by down times.98+type pasteLayout struct {99+ mode lineMode100+ dst Rect101+ tw, th int // a tile's size; a whole line's length along it102+ across, down int103+}104+105+// off is the offset within the copy that the cell at a receives.106+func (p pasteLayout) off(a Addr) Addr {107+ return Addr{Col: (a.Col - p.dst.From.Col) % p.tw, Row: (a.Row - p.dst.From.Row) % p.th}108+}109+110+// layout is where pasting c into dst lands. Like Sheets, a destination111+// that is an exact multiple of the clip's size is tiled; otherwise the112+// clip is pasted once at dst's top-left corner. Whole lines pasted at113+// the start of a line land on whole lines.114+func (c *Clip) layout(dst Rect) (pasteLayout, error) {115+ cols, rows := c.Size()116+ p := pasteLayout{mode: c.formats.mode(), tw: cols, th: rows, across: 1, down: 1}117+ switch {118+ case p.mode == lineAll && dst.From == Addr{}:119+ p.tw, p.th = MaxCols, MaxRows120+ case p.mode == lineCols && dst.From.Row == 0:121+ p.th = MaxRows122+ case p.mode == lineRows && dst.From.Col == 0:123+ p.tw = MaxCols124+ default:125+ p.mode = lineBlock126+ }127+ w, h := dst.To.Col-dst.From.Col+1, dst.To.Row-dst.From.Row+1128+ switch p.mode {129+ case lineBlock:130+ if w%cols == 0 && h%rows == 0 {131+ p.across, p.down = w/cols, h/rows132+ }133+ case lineCols:134+ if w%cols == 0 {135+ p.across = w / cols136+ }137+ case lineRows:138+ if h%rows == 0 {139+ p.down = h / rows140+ }141+ }142+ p.dst = Rect{From: dst.From, To: Addr{Col: dst.From.Col + p.across*p.tw - 1, Row: dst.From.Row + p.down*p.th - 1}}143+ if !p.dst.To.Valid() {144+ return pasteLayout{}, ErrPasteEdge145+ }146+ return p, nil147+}148+149+// MoveRange is the range cutting c moves when pasted at to: the whole150+// lines copied when to starts a line, else the copied cells.151+func (c *Clip) MoveRange(to Addr) Rect {152+ if p, err := c.layout(Rect{From: to, To: to}); err == nil && p.mode != lineBlock {153+ return c.Range154+ }155+ return c.Src156+}157+158+// pasteFormats has the cells p covers show what the copy f showed: whole159+// lines take its line formats, and cells whose lines don't show what160+// their source did get formatting of their own.161+func (s *Sheet) pasteFormats(f *clipFormats, p pasteLayout) {162+ s.pasteLines(f, p)163+ for _, a := range s.formatTargets(f, p) {164+ s.placeFormat(a, f.want(p.off(a)))165+ }166+}167+168+// pasteLines gives the whole lines p covers the copy's line formats.169+func (s *Sheet) pasteLines(f *clipFormats, p pasteLayout) {170+ switch p.mode {171+ case lineAll:172+ s.setLine(false, wholeSheet, f.sheet)173+ for _, n := range unionKeys(s.lines.cols, f.colFmt) {174+ s.setLine(false, n, f.colFmt[n])175+ }176+ for _, n := range unionKeys(s.lines.rows, f.rowFmt) {177+ s.setLine(true, n, f.rowFmt[n])178+ }179+ case lineCols:180+ for i := range p.across * p.tw {181+ s.setLine(false, p.dst.From.Col+i, f.carry(f.colFmt[i%p.tw], s.lines.sheet))182+ }183+ case lineRows:184+ for i := range p.down * p.th {185+ s.setLine(true, p.dst.From.Row+i, f.carry(f.rowFmt[i%p.th], s.lines.sheet))186+ }187+ }188+}189+190+// formatTargets are the cells of p.dst that may need formatting of their191+// own: those stored, and where a formatted line crosses the pasted lines192+// or, for a block, every cell when lines are formatted on either side,193+// up to materializeLimit cells made.194+func (s *Sheet) formatTargets(f *clipFormats, p pasteLayout) []Addr {195+ targets := s.cellsIn(p.dst)196+ for _, r := range s.formatAreas(f, p) {197+ for row := r.From.Row; row <= r.To.Row; row++ {198+ for c := r.From.Col; c <= r.To.Col; c++ {199+ if len(targets) > materializeLimit {200+ return targets201+ }202+ if a := (Addr{Col: c, Row: row}); s.cells.get(a) == nil {203+ targets = append(targets, a)204+ }205+ }206+ }207+ }208+ return targets209+}210+211+// formatAreas are the parts of p.dst whose blank cells may show other212+// than their source: where formatted rows cross pasted columns, formatted213+// columns cross pasted rows, or a whole block when lines are formatted214+// on either side and it is small enough.215+func (s *Sheet) formatAreas(f *clipFormats, p pasteLayout) []Rect {216+ d := p.dst217+ var out []Rect218+ switch p.mode {219+ case lineCols:220+ for _, n := range unionKeys(s.lines.rows, f.rowFmt) {221+ out = append(out, Rect{From: Addr{Col: d.From.Col, Row: n}, To: Addr{Col: d.To.Col, Row: n}})222+ }223+ case lineRows:224+ for _, n := range unionKeys(s.lines.cols, f.colFmt) {225+ out = append(out, Rect{From: Addr{Col: n, Row: d.From.Row}, To: Addr{Col: n, Row: d.To.Row}})226+ }227+ case lineBlock:228+ area := (d.To.Col - d.From.Col + 1) * (d.To.Row - d.From.Row + 1)229+ if area <= materializeLimit && (f.lined() || !s.lines.none()) {230+ out = append(out, d)231+ }232+ }233+ return out234+}235+236+// moveFormats has what moved from src on sheet from to dst on s show237+// what it showed there, f, moving whole lines' formats with them.238+func (s *Sheet) moveFormats(from *Sheet, f *clipFormats, src, dst Rect) {239+ p := pasteLayout{mode: f.mode(), dst: dst, across: 1, down: 1,240+ tw: src.To.Col - src.From.Col + 1, th: src.To.Row - src.From.Row + 1}241+ switch p.mode {242+ case lineAll:243+ from.setLine(false, wholeSheet, lineFmt{})244+ for _, n := range slices.Sorted(maps.Keys(from.lines.cols)) {245+ from.setLine(false, n, lineFmt{})246+ }247+ for _, n := range slices.Sorted(maps.Keys(from.lines.rows)) {248+ from.setLine(true, n, lineFmt{})249+ }250+ case lineCols:251+ for n := range f.colFmt {252+ from.setLine(false, src.From.Col+n, lineFmt{})253+ }254+ case lineRows:255+ for n := range f.rowFmt {256+ from.setLine(true, src.From.Row+n, lineFmt{})257+ }258+ }259+ s.pasteFormats(f, p)260+}261+262+// unionKeys are the keys of a and b, in order.263+func unionKeys(a, b map[int]lineFmt) []int {264+ keys := slices.Collect(maps.Keys(a))265+ for n := range b {266+ if _, ok := a[n]; !ok {267+ keys = append(keys, n)268+ }269+ }270+ slices.Sort(keys)271+ return keys272+}
A internal/sheet/linecopy_test.go +184 -0
@@ -0,0 +1,184 @@
1+package sheet2+3+import "testing"4+5+func bold(st *Style) { st.Bold = true }6+7+// Copying whole columns and pasting them at the top of a column carries8+// the column formats themselves: no cells are made for them, and the9+// paste undoes as one step.10+func TestCopyWholeColumnsCarriesFormats(t *testing.T) {11+ s := New()12+ s.Set(at("B1"), "5")13+ s.Set(at("B2"), "6")14+ s.SetFormat(col(1), Preset(FmtCurrency))15+ s.SetStyle(at1("B2"), bold)16+ s.SetFormat(col(3), Preset(FmtPercent)) // D, to be replaced17+ s.SetStyle(rowRect(4, 4), func(st *Style) { st.Italic = true })18+ cells := s.cells.len()19+20+ dst, err := s.Paste(s.Copy(col(1)), at1("D1"), false)21+ if err != nil || dst != col(3) {22+ t.Fatalf("pasted at %v, %v; want D:D", dst, err)23+ }24+ if f := s.lines.cols[3].Format; f.Kind != FmtCurrency {25+ t.Errorf("column D's format = %v, want currency", f.Kind)26+ }27+ for _, a := range []string{"D1", "D2", "D900000"} {28+ if f := s.DisplayFormat(at(a)); f.Kind != FmtCurrency {29+ t.Errorf("%s shows %v, want currency", a, f.Kind)30+ }31+ }32+ if !s.CellStyle(at("D2")).Bold || s.CellStyle(at("D3")).Bold {33+ t.Error("B2's bold didn't travel to D2 alone")34+ }35+ if !s.CellStyle(at("D5")).Italic {36+ t.Error("row 5's italic is lost where it crosses column D")37+ }38+ if n := s.cells.len(); n != cells+2 {39+ t.Errorf("%d cells after the paste, want %d: only B1 and B2's copies", n, cells+2)40+ }41+ s.Undo()42+ if f := s.DisplayFormat(at("D9")); f.Kind != FmtPercent {43+ t.Errorf("after undo D9 shows %v, want percent again", f.Kind)44+ }45+ if s.cells.get(at("D1")) != nil {46+ t.Error("undo left D1")47+ }48+49+ // Pasted anywhere else, the copy is a block of cells showing what50+ // the column showed.51+ if _, err := s.Paste(s.Copy(col(1)), at1("F5"), false); err != nil {52+ t.Fatal(err)53+ }54+ if s.DisplayFormat(at("F6")).Kind != FmtCurrency || !s.CellStyle(at("F6")).Bold || s.lines.cols[5] != (lineFmt{}) {55+ t.Errorf("F6 shows %v bold %v; column F %+v", s.DisplayFormat(at("F6")).Kind, s.CellStyle(at("F6")).Bold, s.lines.cols[5])56+ }57+ if f := s.DisplayFormat(at("F7")); !f.IsZero() {58+ t.Errorf("F7 shows %v, past the copied cells", f.Kind)59+ }60+}61+62+// Whole rows carry their row formats, and filling whole rows down63+// copies the row's format to the rows filled.64+func TestCopyWholeRowsCarriesFormats(t *testing.T) {65+ s := New()66+ s.Set(at("A2"), "1")67+ s.SetStyle(rowRect(1, 1), bold)68+ if _, err := s.Paste(s.Copy(rowRect(1, 1)), at1("A7"), false); err != nil {69+ t.Fatal(err)70+ }71+ if !s.lines.rows[6].Style.Bold || s.Value(at("A7")).Num != 1 {72+ t.Errorf("row 7 = %+v, A7 = %v", s.lines.rows[6], s.Value(at("A7")))73+ }74+ if _, err := s.FillDown(rowRect(6, 9)); err != nil {75+ t.Fatal(err)76+ }77+ if !s.lines.rows[9].Style.Bold || !s.CellStyle(at("ZZ10")).Bold {78+ t.Errorf("fill down didn't carry row 7's bold to row 10: %+v", s.lines.rows[9])79+ }80+}81+82+// A block pasted into cells shows the formats its source cells showed,83+// from their own, their row's, column's or the sheet's, and replaces84+// the formats of the lines it lands on.85+func TestPasteBlockAppliesEffectiveFormats(t *testing.T) {86+ s := New()87+ s.Set(at("A1"), "1")88+ s.SetFormat(col(0), Preset(FmtCurrency))89+ s.SetStyle(rowRect(1, 1), bold)90+ s.SetFormat(col(4), Preset(FmtPercent)) // E91+ s.Set(at("G1"), "2") // plain92+93+ if _, err := s.Paste(s.Copy(Rect{From: at("A1"), To: at("A3")}), at1("C1"), false); err != nil {94+ t.Fatal(err)95+ }96+ for _, a := range []string{"C1", "C2", "C3"} {97+ if f := s.DisplayFormat(at(a)); f.Kind != FmtCurrency {98+ t.Errorf("%s shows %v, want currency", a, f.Kind)99+ }100+ }101+ if !s.CellStyle(at("C2")).Bold {102+ t.Error("C2 lost row 2's bold")103+ }104+ if f := s.DisplayFormat(at("C4")); !f.IsZero() {105+ t.Errorf("C4 shows %v, past the paste", f.Kind)106+ }107+108+ // Plain cells pasted into a percent column show plain, as their109+ // source did.110+ if _, err := s.Paste(s.Copy(Rect{From: at("G1"), To: at("G2")}), at1("E1"), false); err != nil {111+ t.Fatal(err)112+ }113+ if f := s.DisplayFormat(at("E1")); !f.IsZero() {114+ t.Errorf("E1 shows %v, want the plain format it was copied with", f.Kind)115+ }116+ if f := s.DisplayFormat(at("E5")); f.Kind != FmtPercent {117+ t.Errorf("E5 shows %v, want its column's percent", f.Kind)118+ }119+120+ // Pasting values only keeps the destination's formats.121+ if _, err := s.Paste(s.Copy(at1("G1")), at1("E7"), true); err != nil {122+ t.Fatal(err)123+ }124+ if f := s.DisplayFormat(at("E7")); f.Kind != FmtPercent {125+ t.Errorf("E7 shows %v after pasting values, want percent", f.Kind)126+ }127+}128+129+// Cutting whole columns moves their formats, leaving the source plain;130+// a cut block takes the formats its cells showed.131+func TestMoveCarriesFormats(t *testing.T) {132+ w := NewBook()133+ s := w.Sheets()[0]134+ s.Set(at("B1"), "5")135+ s.SetFormat(col(1), Preset(FmtCurrency))136+ c := s.Copy(col(1))137+ if r := c.MoveRange(at("F1")); r != col(1) {138+ t.Fatalf("a cut of B:B pasted at F1 moves %v", r)139+ }140+ if r := c.MoveRange(at("F3")); r != at1("B1") {141+ t.Fatalf("a cut of B:B pasted at F3 moves %v", r)142+ }143+ if _, err := s.Move(col(1), at("F1")); err != nil {144+ t.Fatal(err)145+ }146+ if s.DisplayFormat(at("F9")).Kind != FmtCurrency || !s.DisplayFormat(at("B9")).IsZero() {147+ t.Errorf("after moving B:B to F:F, F9 shows %v and B9 %v", s.DisplayFormat(at("F9")).Kind, s.DisplayFormat(at("B9")).Kind)148+ }149+ s.Undo()150+ if s.DisplayFormat(at("B9")).Kind != FmtCurrency || !s.DisplayFormat(at("F9")).IsZero() {151+ t.Error("undo didn't bring column B's format back")152+ }153+154+ // To another sheet.155+ o, _ := w.AddSheet("Other", 1)156+ if _, err := s.MoveTo(o, col(1), at("C1")); err != nil {157+ t.Fatal(err)158+ }159+ if o.DisplayFormat(at("C1")).Kind != FmtCurrency || o.Value(at("C1")).Num != 5 || !s.DisplayFormat(at("B1")).IsZero() {160+ t.Errorf("Other!C1 shows %v %v; B1 %v", o.DisplayFormat(at("C1")).Kind, o.Value(at("C1")), s.DisplayFormat(at("B1")).Kind)161+ }162+163+ // A block keeps its formats; the source falls back on its lines.164+ o.Set(at("C2"), "7")165+ if _, err := o.MoveTo(s, Rect{From: at("C1"), To: at("C2")}, at("E4")); err != nil {166+ t.Fatal(err)167+ }168+ if s.DisplayFormat(at("E5")).Kind != FmtCurrency || o.DisplayFormat(at("C1")).Kind != FmtCurrency {169+ t.Errorf("E5 shows %v, Other!C1 %v", s.DisplayFormat(at("E5")).Kind, o.DisplayFormat(at("C1")).Kind)170+ }171+}172+173+// Pasting a column format re-infers formulas reading the column's blanks.174+func TestPasteColumnReinfers(t *testing.T) {175+ s := New()176+ s.SetFormat(col(0), Preset(FmtCurrency))177+ s.Set(at("C1"), "=D5*2")178+ if _, err := s.Paste(s.Copy(col(0)), at1("D1"), false); err != nil {179+ t.Fatal(err)180+ }181+ if f := s.DisplayFormat(at("C1")); f.Kind != FmtCurrency {182+ t.Errorf("C1 shows %v, want currency from column D", f.Kind)183+ }184+}
M internal/sheet/lines.go +9 -2
M internal/sheet/move.go +9 -2
M internal/sheet/ops.go +47 -25
@@ -169,21 +169,46 @@ func (s *Sheet) shiftWidths(sp formula.Span) {
169169 }170170 }171171 172-// Clip is a copied range: snapshots of its cells taken at copy time.172+// Clip is a copied range: snapshots of its cells, and of the formatting173+// they show, taken at copy time.173174 type Clip struct {174- Src Rect175- cells map[Addr]*Cell // by offset from Src.From175+ Range Rect // the range copied176+ Src Rect // Range trimmed to its cells when whole lines177+ cells map[Addr]*Cell // by offset from Src.From178+ formats clipFormats176179 }177180 178-// Copy snapshots the cells in r for pasting.181+// Copy snapshots the cells in r, and what formatting they show, for182+// pasting. Whole columns or rows are trimmed to the cells they hold,183+// keeping their line formats.179184 func (s *Sheet) Copy(r Rect) *Clip {180- c := &Clip{Src: r, cells: map[Addr]*Cell{}}185+ c := &Clip{Range: r, Src: s.trimLines(r), cells: map[Addr]*Cell{}, formats: s.copyFormats(r)}181186 for _, a := range s.cellsIn(r) {182187 c.cells[Addr{Col: a.Col - r.From.Col, Row: a.Row - r.From.Row}] = s.cells.get(a).plain()183188 }184189 return c185190 }186191 192+// trimLines trims whole columns of r to the rows holding cells, and193+// whole rows to the columns holding cells: no point copying a million194+// blanks.195+func (s *Sheet) trimLines(r Rect) Rect {196+ if !r.AllRows() && !r.AllCols() {197+ return r198+ }199+ held, ok := s.cells.bounds(r)200+ if !ok {201+ held.To = r.From202+ }203+ if r.AllRows() {204+ r.To.Row = max(held.To.Row, r.From.Row)205+ }206+ if r.AllCols() {207+ r.To.Col = max(held.To.Col, r.From.Col)208+ }209+ return r210+}211+187212 // Size returns the clip's width and height in cells.188213 func (c *Clip) Size() (cols, rows int) {189214 return c.Src.To.Col - c.Src.From.Col + 1, c.Src.To.Row - c.Src.From.Row + 1
@@ -218,23 +243,20 @@ func (c *Clip) Text() [][]string {
218243 219244 // Paste writes the clip into dst and returns the range written. Formulas220245 // shift their relative references by the distance pasted, as in Sheets;221-// with values set, only the computed values are pasted. Like Sheets, a222-// destination that is an exact multiple of the clip's size is tiled;223-// otherwise the clip is pasted once at dst's top-left corner.246+// with values set, only the computed values are pasted, keeping the247+// destination's formatting. Otherwise each cell shows the formatting its248+// source showed (see clipfmt.go). Like Sheets, a destination that is an249+// exact multiple of the clip's size is tiled; otherwise the clip is250+// pasted once at dst's top-left corner.224251 func (s *Sheet) Paste(c *Clip, dst Rect, values bool) (Rect, error) {225- cols, rows := c.Size()226- w, h := dst.To.Col-dst.From.Col+1, dst.To.Row-dst.From.Row+1227- if w%cols != 0 || h%rows != 0 {228- dst.To = Addr{Col: dst.From.Col + cols - 1, Row: dst.From.Row + rows - 1}229- w, h = cols, rows230- }231- if !dst.To.Valid() {232- return Rect{}, ErrPasteEdge252+ p, err := c.layout(dst)253+ if err != nil {254+ return Rect{}, err233255 }234- tilesAcross, tilesDown := w/cols, h/rows235- if len(c.cells)*tilesAcross*tilesDown > MaxCells() {256+ if len(c.cells)*p.across*p.down > MaxCells() {236257 return Rect{}, ErrFillTooBig237258 }259+ dst = p.dst238260 label := "paste into " + dst.String()239261 if values {240262 label = "paste values into " + dst.String()
@@ -242,24 +264,24 @@ func (s *Sheet) Paste(c *Clip, dst Rect, values bool) (Rect, error) {
242264 // Blank parts of the clip clear what they land on, and each of its243265 // cells is placed in every tile: the cost is the cells written and244266 // cleared, not dst's area.245- offset := func(a Addr) Addr {246- return Addr{Col: (a.Col - dst.From.Col) % cols, Row: (a.Row - dst.From.Row) % rows}247- }248267 s.change(label, dst, func() {249268 for _, a := range s.cellsIn(dst) {250- if c.cells[offset(a)] == nil {269+ if c.cells[p.off(a)] == nil {251270 s.place(a, nil)252271 }253272 }254273 for off, cell := range c.cells {255274 from := Addr{Col: c.Src.From.Col + off.Col, Row: c.Src.From.Row + off.Row}256- for tr := range tilesDown {257- for tc := range tilesAcross {258- to := Addr{Col: dst.From.Col + tc*cols + off.Col, Row: dst.From.Row + tr*rows + off.Row}275+ for tr := range p.down {276+ for tc := range p.across {277+ to := Addr{Col: dst.From.Col + tc*p.tw + off.Col, Row: dst.From.Row + tr*p.th + off.Row}259278 s.pasteCell(to, cell, to.Col-from.Col, to.Row-from.Row, values)260279 }261280 }262281 }282+ if !values {283+ s.pasteFormats(&c.formats, p)284+ }263285 })264286 return dst, nil265287 }
M internal/ui/clipboard.go +16 -24
@@ -11,7 +11,10 @@ import (
1111 1212 // Copy, cut and paste follow Sheets: Ctrl+C copies the selection and1313 // marks it until Esc or the next edit, Ctrl+V pastes with references14-// adjusted, Ctrl+X then Ctrl+V moves. The copied values also go to the14+// 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 the1518 // system clipboard as TSV (OSC 52), and a multi-cell paste from the1619 // terminal fills a block of cells.1720
@@ -39,7 +42,7 @@ func init() {
3942 4043 // copy puts the selection on the clipboard and the system clipboard.4144 func (m *Model) copy(cut bool) tea.Cmd {42- clip := m.sheet.Copy(m.copyRange())45+ clip := m.sheet.Copy(m.selection())4346 m.copied = clipboard{clip: clip, sheet: m.sheet, cut: cut, marked: true}4447 text := clip.Text()4548 if text == nil {
@@ -49,21 +52,6 @@ func (m *Model) copy(cut bool) tea.Cmd {
4952 return tea.SetClipboard(formatTSV(text))5053 }5154 52-// copyRange is the selection, trimmed to the data when whole rows or53-// columns are selected, as there's no point copying thousands of blanks.54-func (g *grid) copyRange() sheet.Rect {55- r := g.selection()56- if g.whole == wholeNone {57- return r58- }59- used, ok := g.sheet.UsedRange()60- r.To = sheet.Addr{Col: min(r.To.Col, used.To.Col), Row: min(r.To.Row, used.To.Row)}61- if !ok || r.To.Col < r.From.Col || r.To.Row < r.From.Row {62- r.To = r.From63- }64- return r65-}66-6755 // paste pastes the clipboard into the selection and selects what it6856 // wrote.6957 func (m *Model) paste(values bool) tea.Cmd {
@@ -72,7 +60,7 @@ func (m *Model) paste(values bool) tea.Cmd {
7260 m.note = "Nothing to paste: copy with Ctrl+C first"7361 return nil7462 }75- var r sheet.Rect63+ var r, moved sheet.Rect7664 var err error7765 if c.cut && !values {7866 if !c.sheet.Live() {
@@ -80,7 +68,8 @@ func (m *Model) paste(values bool) tea.Cmd {
8068 m.note = "Nothing to move: the cut cells' sheet was deleted"8169 return nil8270 }83- r, err = c.sheet.MoveTo(m.sheet, c.clip.Src, m.cur)71+ moved = c.clip.MoveRange(m.cur)72+ r, err = c.sheet.MoveTo(m.sheet, moved, m.cur)8473 m.copied = clipboard{}8574 } else {8675 r, err = m.sheet.Paste(c.clip, m.selection(), values)
@@ -93,7 +82,7 @@ func (m *Model) paste(values bool) tea.Cmd {
9382 m.selectRect(r)9483 switch {9584 case c.cut && !values:96- m.note = "Moved " + c.label(m.sheet) + " to " + r.String()85+ m.note = "Moved " + c.labelOf(m.sheet, moved) + " to " + r.String()9786 case values:9887 m.note = "Pasted values into " + countCells(r) + " at " + r.String()9988 default:
@@ -104,16 +93,19 @@ func (m *Model) paste(values bool) tea.Cmd {
10493 10594 // marks reports whether a, on the sheet shown, shows the copy marker.10695 func (c *clipboard) marks(shown *sheet.Sheet, a sheet.Addr) bool {107- return c.marked && c.sheet == shown && c.clip.Src.Contains(a)96+ return c.marked && c.sheet == shown && c.clip.Range.Contains(a)10897 }10998 11099 // label names the copied range, with its sheet when that isn't the111100 // one shown: A1:B3, or Sheet1!A1:B3.112-func (c *clipboard) label(shown *sheet.Sheet) string {101+func (c *clipboard) label(shown *sheet.Sheet) string { return c.labelOf(shown, c.clip.Range) }102+103+// labelOf names r on the copied range's sheet.104+func (c *clipboard) labelOf(shown *sheet.Sheet, r sheet.Rect) string {113105 if c.sheet != shown {114- return sheet.Qualified(c.sheet.Name(), c.clip.Src)106+ return sheet.Qualified(c.sheet.Name(), r)115107 }116- return c.clip.Src.String()108+ return r.String()117109 }118110 119111 // clearMark hides the copy marker; a pending cut is cancelled, as in
M internal/ui/edit.go +12 -1
A internal/ui/linecopy_test.go +40 -0
@@ -0,0 +1,40 @@
1+package ui2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/012/internal/sheet"7+)8+9+// Copying a whole column and pasting it at the top of another carries10+// its column format; cutting one moves it, one undo step each.11+func TestCopyColumnCarriesItsFormat(t *testing.T) {12+ m := newModel()13+ press(t, m, "<right>", "5", "<enter>", "<up>", "<ctrl+space>")14+ m.runCommand("format.currency")15+ press(t, m, "<ctrl+c>", "<esc>", "<right>", "<right>", "<ctrl+v>")16+ if got := line(m, 2); got != "Pasted 1 column at D:D" {17+ t.Errorf("context line %q", got)18+ }19+ if f := m.sheet.DisplayFormat(sheet.Addr{Col: 3, Row: 500}); f.Kind != sheet.FmtCurrency {20+ t.Errorf("D501 shows %v, want column B's currency", f.Kind)21+ }22+ press(t, m, "<ctrl+z>")23+ if f := m.sheet.DisplayFormat(sheet.Addr{Col: 3, Row: 500}); !f.IsZero() {24+ t.Errorf("after undo D501 shows %v", f.Kind)25+ }26+27+ // Cut column B and paste it at F1: its format goes along.28+ press(t, m, "<ctrl+home>", "<right>", "<ctrl+space>", "<ctrl+x>")29+ m.clearSelection()30+ m.cur = sheet.Addr{Col: 5}31+ press(t, m, "<ctrl+v>")32+ if got := line(m, 2); got != "Moved B:B to F:F" {33+ t.Errorf("context line %q", got)34+ }35+ if input(m, "F1") != "5" || m.sheet.DisplayFormat(sheet.Addr{Col: 5, Row: 9}).Kind != sheet.FmtCurrency ||36+ !m.sheet.DisplayFormat(sheet.Addr{Col: 1, Row: 9}).IsZero() {37+ t.Errorf("F1 %q, F10 %v, B10 %v", input(m, "F1"), m.sheet.DisplayFormat(sheet.Addr{Col: 5, Row: 9}).Kind,38+ m.sheet.DisplayFormat(sheet.Addr{Col: 1, Row: 9}).Kind)39+ }40+}