The sheet shown, the active cell, scroll position, window size and selection become a grid type. The 39 methods that map rows and columns to the screen, move around the sheet and select depend only on it, so they move off Model onto grid, where they can't reach modes, overlays or input state.
- commit
- cb0293743341eed08d8ef7d1c4da312afb85e4dc
- parent
- 3f83132cf8124f32b871a14953bee7b9cdc34499
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 34 | +++--- |
| M | internal/ | 8 | +- |
| M | internal/ | 8 | - |
| M | internal/ | 20 | ++-- |
| M | internal/ | 6 | +- |
| M | internal/ | 24 | ++-- |
| A | internal/ | 25 | ++++ |
| M | internal/ | 2 | +- |
| M | internal/ | 103 | +----------------- |
| M | internal/ | 48 | -------- |
| M | internal/ | 8 | +- |
| M | internal/ | 6 | - |
| M | internal/ | 178 | ++++++++++++++++++++++-------- |
| M | internal/ | 137 | +++++++++++++++++------ |
| M | internal/ | 14 | +- |
| M | internal/ | 10 | +- |
@@ -105,20 +105,20 @@ func (m *Model) insertChart() tea.Cmd {
105105 }106106 107107 // showChart scrolls so chart i is on screen, as far as it fits.108-func (m *Model) showChart(i int) {109- charts := m.sheet.Charts()108+func (g *grid) showChart(i int) {109+ charts := g.sheet.Charts()110110 if i < 0 || i >= len(charts) {111111 return112112 }113113 c := charts[i]114- rows := m.visibleRows()115- if c.At.Row < m.top || c.At.Row+c.H > m.top+rows {116- m.top = clamp(c.At.Row+c.H-rows, 0, c.At.Row)117- }118- if c.At.Col < m.left || m.colStart(c.At.Col)+c.W > m.width {119- m.left = c.At.Col120- for m.left > 0 && m.colStart(c.At.Col)+c.W+m.sheet.ColWidth(m.left-1) <= m.width {121- m.left--114+ rows := g.visibleRows()115+ if c.At.Row < g.top || c.At.Row+c.H > g.top+rows {116+ g.top = clamp(c.At.Row+c.H-rows, 0, c.At.Row)117+ }118+ if c.At.Col < g.left || g.colStart(c.At.Col)+c.W > g.width {119+ g.left = c.At.Col120+ for g.left > 0 && g.colStart(c.At.Col)+c.W+g.sheet.ColWidth(g.left-1) <= g.width {121+ g.left--122122 }123123 }124124 }@@ -152,8 +152,8 @@ func chartInner(c sheet.Chart) (w, h int) { return c.W - 4, c.H - 2 }
152152 153153 // chartScreen returns where chart c's top-left corner is on screen; it154154 // may be off screen.155-func (m *Model) chartScreen(c sheet.Chart) (x, y int) {156- return m.colStart(c.At.Col), gridTop + c.At.Row - m.top155+func (g *grid) chartScreen(c sheet.Chart) (x, y int) {156+ return g.colStart(c.At.Col), gridTop + c.At.Row - g.top157157 }158158 159159 // chartAt returns the topmost chart drawn under x, y, or -1.@@ -499,19 +499,19 @@ func (s *chartSel) mouse(m *Model, e mouseEvent) tea.Cmd {
499499 500500 // chartCellAt returns the cell a chart's corner snaps to when dragged to501501 // screen position x, y, which may be past the grid's edges.502-func (m *Model) chartCellAt(x, y int) sheet.Addr {503- a := sheet.Addr{Row: max(m.top+y-gridTop, 0), Col: m.left}502+func (g *grid) chartCellAt(x, y int) sheet.Addr {503+ a := sheet.Addr{Row: max(g.top+y-gridTop, 0), Col: g.left}504504 switch {505505 case x >= rowHdrW:506- if col, _, ok := m.colSpan(x); ok {506+ if col, _, ok := g.colSpan(x); ok {507507 a.Col = col508508 } else {509- a.Col = m.left + m.visibleCols(m.left) - 1509+ a.Col = g.left + g.visibleCols(g.left) - 1510510 }511511 default:512512 for cx := rowHdrW; cx > x && a.Col > 0; {513513 a.Col--514- cx -= m.sheet.ColWidth(a.Col)514+ cx -= g.sheet.ColWidth(a.Col)515515 }516516 }517517 return clampAddr(a)@@ -46,12 +46,12 @@ func (m *Model) copy(cut bool) tea.Cmd {
4646 4747 // copyRange is the selection, trimmed to the data when whole rows or4848 // columns are selected, as there's no point copying thousands of blanks.49-func (m *Model) copyRange() sheet.Rect {50- r := m.selection()51- if m.whole == wholeNone {49+func (g *grid) copyRange() sheet.Rect {50+ r := g.selection()51+ if g.whole == wholeNone {5252 return r5353 }54- used, ok := m.sheet.UsedRange()54+ used, ok := g.sheet.UsedRange()5555 r.To = sheet.Addr{Col: min(r.To.Col, used.To.Col), Row: min(r.To.Row, used.To.Row)}5656 if !ok || r.To.Col < r.From.Col || r.To.Row < r.From.Row {5757 r.To = r.From@@ -2,8 +2,6 @@ package ui
22 33 import (44 tea "charm.land/bubbletea/v2"5-6- "github.com/FelineStateMachine/012/internal/sheet"75 )86 97 // Right-click menus follow Sheets: the cell menu offers clipboard, insert@@ -80,9 +78,3 @@ func (m *Model) rightClick(x, y int) {
8078 m.showContextMenu(rowMenu, x, y+1)8179 }8280 }83-84-// cellPos returns the screen position of a visible cell's left edge.85-func (m *Model) cellPos(a sheet.Addr) (x, y int) {86- y, _ = m.rowY(a.Row)87- return m.colStart(a.Col), y88-}@@ -112,23 +112,23 @@ func (m *Model) afterHistory(verb, none string, c sheet.Change, ok bool) {
112112 }113113 114114 // selectRect selects r, as whole columns or rows when it spans the sheet.115-func (m *Model) selectRect(r sheet.Rect) {115+func (g *grid) selectRect(r sheet.Rect) {116116 allRows := r.From.Row == 0 && r.To.Row == sheet.MaxRows-1117117 allCols := r.From.Col == 0 && r.To.Col == sheet.MaxCols-1118118 switch {119119 case allRows && allCols:120- m.selecting, m.whole, m.ext = true, wholeAll, m.cur120+ g.selecting, g.whole, g.ext = true, wholeAll, g.cur121121 case allRows:122- m.cur.Col = r.From.Col123- m.ext = sheet.Addr{Col: r.To.Col, Row: m.cur.Row}124- m.selecting, m.whole = true, wholeCols122+ g.cur.Col = r.From.Col123+ g.ext = sheet.Addr{Col: r.To.Col, Row: g.cur.Row}124+ g.selecting, g.whole = true, wholeCols125125 case allCols:126- m.cur.Row = r.From.Row127- m.ext = sheet.Addr{Col: m.cur.Col, Row: r.To.Row}128- m.selecting, m.whole = true, wholeRows126+ g.cur.Row = r.From.Row127+ g.ext = sheet.Addr{Col: g.cur.Col, Row: r.To.Row}128+ g.selecting, g.whole = true, wholeRows129129 default:130- m.cur, m.ext = r.From, r.To131- m.selecting, m.whole = r.From != r.To, wholeNone130+ g.cur, g.ext = r.From, r.To131+ g.selecting, g.whole = r.From != r.To, wholeNone132132 }133133 }134134 @@ -16,11 +16,11 @@ import (
1616 1717 // fillCorner is the cell with the fill handle: the bottom-right corner of1818 // the selection. Whole rows and columns have none.19-func (m *Model) fillCorner() sheet.Addr {20- if m.whole != wholeNone {19+func (g *grid) fillCorner() sheet.Addr {20+ if g.whole != wholeNone {2121 return sheet.Addr{Col: -1, Row: -1}2222 }23- return m.selection().To23+ return g.selection().To2424 }2525 2626 // showFillHandle reports whether the fill handle is drawn in cell a:@@ -58,12 +58,12 @@ func (m *Model) createFilter() tea.Cmd {
5858 // dataRange is the range data commands act on: the selection when there5959 // is one, cut to the data in it, or else the block of data around the6060 // active cell.61-func (m *Model) dataRange() sheet.Rect {62- if !m.hasRange() {63- return m.sheet.Region(m.cur)61+func (g *grid) dataRange() sheet.Rect {62+ if !g.hasRange() {63+ return g.sheet.Region(g.cur)6464 }65- r := m.selection()66- if used, ok := m.sheet.UsedRange(); ok {65+ r := g.selection()66+ if used, ok := g.sheet.UsedRange(); ok {6767 r.To.Row = max(min(r.To.Row, used.To.Row), r.From.Row)6868 r.To.Col = max(min(r.To.Col, used.To.Col), r.From.Col)6969 }@@ -72,26 +72,26 @@ func (m *Model) dataRange() sheet.Rect {
7272 7373 // filterMark is the filter button in column c's header, if c is in the7474 // filter's range, and whether the column's filter hides anything.75-func (m *Model) filterMark(c int) (string, bool) {76- r, on := m.sheet.FilterRange()75+func (g *grid) filterMark(c int) (string, bool) {76+ r, on := g.sheet.FilterRange()7777 switch {7878 case !on || c < r.From.Col || c > r.To.Col:7979 return "", false80- case m.sheet.ColumnFiltered(c):80+ case g.sheet.ColumnFiltered(c):8181 return "▼", true8282 }8383 return "▾", false8484 }8585 8686 // filterButtonX is the screen x of column c's filter button, or -1.87-func (m *Model) filterButtonX(c int) int {87+func (g *grid) filterButtonX(c int) int {8888 name := sheet.ColName(c)89- w := m.sheet.ColWidth(c)90- if mark, _ := m.filterMark(c); mark == "" || w < len(name)+3 {89+ w := g.sheet.ColWidth(c)90+ if mark, _ := g.filterMark(c); mark == "" || w < len(name)+3 {9191 return -19292 }9393 lw := len(name) + 294- return m.colStart(c) + (w-lw)/2 + lw - 194+ return g.colStart(c) + (w-lw)/2 + lw - 19595 }9696 9797 // filterPicker is the values list and condition for one column.@@ -0,0 +1,25 @@
1+package ui2+3+import (4+ "github.com/FelineStateMachine/012/internal/sheet"5+)6+7+// grid is the sheet as the window shows it: which sheet, the active cell,8+// the scroll position, the window size and the selection. Its methods map9+// rows and columns to the screen and back (panes.go), move around the10+// sheet and select (selection.go); they know nothing of modes, overlays11+// or input, which belong to Model. Model embeds it, so m.sheet and m.cur12+// read as the model's own.13+type grid struct {14+ sheet *sheet.Sheet // the sheet shown; its workbook is the file15+ cur sheet.Addr // the active cell16+ top, left int // first visible scrolling row and column17+ width, height int // the window18+19+ // The selection: see selection.go.20+ selecting bool21+ ext sheet.Addr // the moving corner of the selection22+ whole wholeKind23+}24+25+func (g *grid) book() *sheet.Workbook { return g.sheet.Book() }@@ -253,7 +253,7 @@ func (m *Model) fail(msg string) {
253253 // session rather than the sheet carries over: the window, theme, terminal254254 // state and the JEV connection.255255 func (m *Model) reset(s *sheet.Sheet, filename string) {256- *m = Model{sheet: s, filename: filename, width: m.width, height: m.height, th: m.th, term: m.term, jev: m.jev, lastChart: -1}256+ *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, lastChart: -1}257257 }258258 259259 func isDigits(s string) bool {@@ -48,7 +48,10 @@ const doubleClick = 400 * time.Millisecond
4848 4949 // Model is the whole application state.5050 type Model struct {51- sheet *sheet.Sheet // the sheet shown; its workbook is the file51+ // grid is the sheet shown, the active cell, the scroll position, the52+ // window size and the selection; see grid.go.53+ grid54+5255 filename string5356 changed bool5457 saved int // the sheet's StateID when last saved or loaded@@ -58,15 +61,6 @@ type Model struct {
5861 5962 quitAfterSave bool // "Save and quit" is waiting for the save to finish6063 61- cur sheet.Addr // the active cell62- top, left int // first visible row and column63- width, height int64-65- // Selection: see selection.go.66- selecting bool67- ext sheet.Addr // the moving corner of the selection68- whole wholeKind69-7064 // Mouse: see mouse.go.7165 drag dragKind7266 lastClick time.Time@@ -121,7 +115,7 @@ type Model struct {
121115 122116 // New returns a model editing s. filename may be empty.123117 func New(s *sheet.Sheet, filename string) *Model {124- return &Model{sheet: s, filename: filename, width: 80, height: 24, th: theme.New(true), term: newTerminal(), lastChart: -1}118+ return &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(), lastChart: -1}125119 }126120 127121 // Init implements tea.Model. It asks the terminal for its background color@@ -281,75 +275,6 @@ func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {
281275 return nil282276 }283277 284-// navigate applies a movement key to a, returning false if it isn't one.285-// Keys follow Google Sheets.286-func (m *Model) navigate(key string, a *sheet.Addr) bool {287- rows, cols := m.scrollRows(), m.visibleCols(m.left)288- switch key {289- case "up":290- a.Row = m.stepRow(a.Row, -1)291- case "down":292- a.Row = m.stepRow(a.Row, 1)293- case "left", "shift+tab":294- a.Col--295- case "right", "tab":296- a.Col++297- case "ctrl+up":298- *a = m.sheet.Edge(*a, 0, -1)299- case "ctrl+down":300- *a = m.sheet.Edge(*a, 0, 1)301- case "ctrl+left":302- *a = m.sheet.Edge(*a, -1, 0)303- case "ctrl+right", "end":304- *a = m.sheet.Edge(*a, 1, 0)305- case "pgup":306- a.Row = m.stepRow(a.Row, -rows)307- m.top = m.stepRow(m.top, -rows)308- case "pgdown":309- a.Row = m.stepRow(a.Row, rows)310- m.top = m.stepRow(m.top, rows)311- case "alt+pgup":312- a.Col -= cols313- m.left -= cols314- case "alt+pgdown":315- a.Col += cols316- m.left += cols317- case "home":318- a.Col = 0319- case "ctrl+home":320- *a = sheet.Addr{}321- case "ctrl+end":322- used, _ := m.sheet.UsedRange()323- *a = used.To324- default:325- return false326- }327- *a = clampAddr(*a)328- a.Row = m.visibleRow(a.Row)329- // Moving into the frozen panes by keyboard scrolls the rest back to330- // the start, as in Sheets (Ctrl+Home shows A1 with row 2 under it).331- if fr, fc := m.frozen(); a.Row < fr || a.Col < fc {332- if a.Row < fr {333- m.top = 0334- }335- if a.Col < fc {336- m.left = 0337- }338- }339- m.clampView()340- return true341-}342-343-func isMoveKey(key string) bool {344- switch key {345- case "up", "down", "left", "right", "tab", "shift+tab", "pgup", "pgdown",346- "alt+pgup", "alt+pgdown", "home", "end", "ctrl+home", "ctrl+end",347- "ctrl+up", "ctrl+down", "ctrl+left", "ctrl+right":348- return true349- }350- return false351-}352-353278 func (m *Model) handleWheel(mouse tea.Mouse) {354279 const step = 3355280 button := mouse.Button@@ -388,24 +313,6 @@ func (m *Model) focus() *sheet.Addr {
388313 return &m.cur389314 }390315 391-func (m *Model) visibleRows() int {392- return max(m.height-gridTop-1, 1)393-}394-395-// visibleCols returns how many whole scrolling columns fit starting at396-// left.397-func (m *Model) visibleCols(left int) int {398- n, w := 0, m.scrollX()399- for c := left; c < sheet.MaxCols; c++ {400- w += m.sheet.ColWidth(c)401- if w > m.width {402- break403- }404- n++405- }406- return max(n, 1)407-}408-409316 // typed returns the printable text of a key press, if any.410317 func typed(k tea.KeyPressMsg) string {411318 if k.Mod.Contains(tea.ModCtrl) || k.Mod.Contains(tea.ModAlt) {@@ -103,54 +103,6 @@ func (m *Model) hitTest(x, y int) hit {
103103 return hit{}104104 }105105 106-// colSpan returns the visible column under x and the x where it starts:107-// a frozen column or a scrolling one, but not the divider between them.108-func (m *Model) colSpan(x int) (col, start int, ok bool) {109- _, fc := m.frozen()110- cx := rowHdrW111- for c := 0; c < fc; c++ {112- w := m.sheet.ColWidth(c)113- if x >= cx && x < cx+w {114- return c, cx, true115- }116- cx += w117- }118- cx = m.scrollX()119- for c := m.left; c < sheet.MaxCols && cx < m.width; c++ {120- w := m.sheet.ColWidth(c)121- if x >= cx && x < cx+w {122- return c, cx, true123- }124- cx += w125- }126- return 0, 0, false127-}128-129-// colStart returns the screen x where column c starts, which may be off130-// screen (or under the frozen columns, for a scrolling column left of131-// m.left).132-func (m *Model) colStart(c int) int {133- _, fc := m.frozen()134- if c < fc {135- x := rowHdrW136- for k := range c {137- x += m.sheet.ColWidth(k)138- }139- return x140- }141- x := m.scrollX()142- if c >= m.left {143- for k := m.left; k < c; k++ {144- x += m.sheet.ColWidth(k)145- }146- return x147- }148- for k := c; k < m.left; k++ {149- x -= m.sheet.ColWidth(k)150- }151- return x152-}153-154106 // dragTarget maps a position during a drag to a cell, clamping to the155107 // visible grid, and reports which way to autoscroll when it's outside.156108 // Dragging from the scrolling area into the frozen panes scrolls back@@ -171,10 +171,10 @@ func (m *Model) editName(n sheet.Name, sel sheet.Rect) {
171171 172172 // namedSelection is the name of the selected range, if it has one, for173173 // the name box, as in Sheets.174-func (m *Model) namedSelection() (string, bool) {175- r := m.selection()176- for _, n := range m.sheet.Names() {177- if !n.Gone() && n.Sheet == m.sheet && n.Range == r {174+func (g *grid) namedSelection() (string, bool) {175+ r := g.selection()176+ for _, n := range g.sheet.Names() {177+ if !n.Gone() && n.Sheet == g.sheet && n.Range == r {178178 return n.Name, true179179 }180180 }@@ -175,12 +175,6 @@ func (m *Model) overlayMouse(msg tea.MouseMsg) tea.Cmd {
175175 return m.overlay.mouse(m, e)176176 }177177 178-// clampBox keeps a w by h box at x, y on screen, shifting it left and up179-// as needed.180-func (m *Model) clampBox(x, y, w, h int) (int, int) {181- return max(min(x, m.width-w), 0), max(min(y, m.height-h), 0)182-}183-184178 // list is the highlighted row and scroll position of a list in a box.185179 type list struct {186180 sel, top int@@ -6,7 +6,7 @@ import (
66 77 // The viewport: frozen rows and columns stay on screen at the top and88 // left while the rest scrolls, split from it by a thin line like a tmux9-// pane border, and rows the filter hides are skipped. m.top and m.left9+// pane border, and rows the filter hides are skipped. top and left1010 // are the first scrolling row and column, never inside the frozen area.1111 // Everything that maps between rows or columns and the screen goes1212 // through here, so drawing, navigation and the mouse agree.@@ -14,15 +14,15 @@ import (
1414 // frozen returns the frozen rows and columns that fit on screen: frozen1515 // panes too big for the window are shown only in part, leaving room for1616 // at least one scrolling row and column.17-func (m *Model) frozen() (rows, cols int) {18- rows, cols = m.sheet.Frozen()19- for rows > 0 && m.frozenLines(rows)+1 >= m.visibleRows() {17+func (g *grid) frozen() (rows, cols int) {18+ rows, cols = g.sheet.Frozen()19+ for rows > 0 && g.frozenLines(rows)+1 >= g.visibleRows() {2020 rows--2121 }2222 w := 02323 for c := range cols {24- w += m.sheet.ColWidth(c)25- if rowHdrW+w+1+3 > m.width {24+ w += g.sheet.ColWidth(c)25+ if rowHdrW+w+1+3 > g.width {2626 cols = c2727 break2828 }@@ -32,10 +32,10 @@ func (m *Model) frozen() (rows, cols int) {
3232 3333 // frozenLines counts the screen lines frozen rows take: those the filter3434 // doesn't hide.35-func (m *Model) frozenLines(rows int) int {35+func (g *grid) frozenLines(rows int) int {3636 n := 03737 for r := range rows {38- if !m.sheet.RowHidden(r) {38+ if !g.sheet.RowHidden(r) {3939 n++4040 }4141 }@@ -44,12 +44,12 @@ func (m *Model) frozenLines(rows int) int {
4444 4545 // scrollRows is how many scrolling rows fit below the frozen ones and4646 // their divider.47-func (m *Model) scrollRows() int {48- fr, _ := m.frozen()47+func (g *grid) scrollRows() int {48+ fr, _ := g.frozen()4949 if fr == 0 {50- return m.visibleRows()50+ return g.visibleRows()5151 }52- return max(m.visibleRows()-m.frozenLines(fr)-1, 1)52+ return max(g.visibleRows()-g.frozenLines(fr)-1, 1)5353 }5454 5555 // divider marks the line between frozen and scrolling rows in@@ -57,22 +57,22 @@ func (m *Model) scrollRows() int {
5757 const divider = -15858 5959 // screenRows returns the row shown on each grid line, top to bottom:60-// frozen rows, the divider, then scrolling rows from m.top, skipping60+// frozen rows, the divider, then scrolling rows from top, skipping6161 // rows the filter hides. Rows past the end of the sheet are left off.62-func (m *Model) screenRows() []int {63- n := m.visibleRows()62+func (g *grid) screenRows() []int {63+ n := g.visibleRows()6464 out := make([]int, 0, n)65- fr, _ := m.frozen()65+ fr, _ := g.frozen()6666 for r := 0; r < fr; r++ {67- if !m.sheet.RowHidden(r) {67+ if !g.sheet.RowHidden(r) {6868 out = append(out, r)6969 }7070 }7171 if fr > 0 {7272 out = append(out, divider)7373 }74- for r := m.top; len(out) < n && r < sheet.MaxRows; r++ {75- if !m.sheet.RowHidden(r) {74+ for r := g.top; len(out) < n && r < sheet.MaxRows; r++ {75+ if !g.sheet.RowHidden(r) {7676 out = append(out, r)7777 }7878 }@@ -81,8 +81,8 @@ func (m *Model) screenRows() []int {
8181 8282 // rowAt returns the row on screen line y, or false for the divider and8383 // lines outside the grid.84-func (m *Model) rowAt(y int) (int, bool) {85- rows := m.screenRows()84+func (g *grid) rowAt(y int) (int, bool) {85+ rows := g.screenRows()8686 if i := y - gridTop; i >= 0 && i < len(rows) && rows[i] != divider {8787 return rows[i], true8888 }@@ -90,8 +90,8 @@ func (m *Model) rowAt(y int) (int, bool) {
9090 }9191 9292 // rowY returns the screen line of row r, or false if it isn't on screen.93-func (m *Model) rowY(r int) (int, bool) {94- for i, row := range m.screenRows() {93+func (g *grid) rowY(r int) (int, bool) {94+ for i, row := range g.screenRows() {9595 if row == r {9696 return gridTop + i, true9797 }@@ -101,14 +101,14 @@ func (m *Model) rowY(r int) (int, bool) {
101101 102102 // stepRow moves n visible rows from r (up for negative n), skipping103103 // hidden rows and stopping at the edges of the sheet.104-func (m *Model) stepRow(r, n int) int {104+func (g *grid) stepRow(r, n int) int {105105 d := 1106106 if n < 0 {107107 d, n = -1, -n108108 }109109 for ; n > 0; n-- {110110 next := r + d111- for next >= 0 && next < sheet.MaxRows && m.sheet.RowHidden(next) {111+ for next >= 0 && next < sheet.MaxRows && g.sheet.RowHidden(next) {112112 next += d113113 }114114 if next < 0 || next >= sheet.MaxRows {@@ -122,23 +122,23 @@ func (m *Model) stepRow(r, n int) int {
122122 // visibleRow returns r, or the nearest row above it the filter doesn't123123 // hide (the header row always shows), so the active cell stays in the124124 // data when its row is filtered out.125-func (m *Model) visibleRow(r int) int {126- if !m.sheet.RowHidden(r) {125+func (g *grid) visibleRow(r int) int {126+ if !g.sheet.RowHidden(r) {127127 return r128128 }129- if prev := m.stepRow(r, -1); prev != r {129+ if prev := g.stepRow(r, -1); prev != r {130130 return prev131131 }132- return m.stepRow(r, 1)132+ return g.stepRow(r, 1)133133 }134134 135135 // scrollX is the screen x where the scrolling columns start: after the136136 // row numbers, the frozen columns and their divider.137-func (m *Model) scrollX() int {138- _, fc := m.frozen()137+func (g *grid) scrollX() int {138+ _, fc := g.frozen()139139 x := rowHdrW140140 for c := range fc {141- x += m.sheet.ColWidth(c)141+ x += g.sheet.ColWidth(c)142142 }143143 if fc > 0 {144144 x++@@ -148,38 +148,116 @@ func (m *Model) scrollX() int {
148148 149149 // clampView keeps the scrolling area out of the frozen panes, e.g. after150150 // freezing more rows than were scrolled past.151-func (m *Model) clampView() {152- fr, fc := m.frozen()153- m.top = clamp(m.top, fr, sheet.MaxRows-1)154- m.left = clamp(m.left, fc, sheet.MaxCols-1)151+func (g *grid) clampView() {152+ fr, fc := g.frozen()153+ g.top = clamp(g.top, fr, sheet.MaxRows-1)154+ g.left = clamp(g.left, fc, sheet.MaxCols-1)155155 }156156 157157 // scrollTo moves the viewport the minimum amount needed to show a. Cells158158 // in the frozen panes are always on screen.159-func (m *Model) scrollTo(a sheet.Addr) {160- m.clampView()161- fr, fc := m.frozen()159+func (g *grid) scrollTo(a sheet.Addr) {160+ g.clampView()161+ fr, fc := g.frozen()162162 if a.Row >= fr {163- if a.Row < m.top {164- m.top = a.Row163+ if a.Row < g.top {164+ g.top = a.Row165165 } else {166166 // Walk up from a over as many visible rows as fit; if that167- // passes m.top, a is already on screen.167+ // passes g.top, a is already on screen.168168 r, n := a.Row, 1169- for r > m.top && n < m.scrollRows() {170- r = m.stepRow(r, -1)169+ for r > g.top && n < g.scrollRows() {170+ r = g.stepRow(r, -1)171171 n++172172 }173- m.top = max(m.top, r)173+ g.top = max(g.top, r)174174 }175175 }176176 if a.Col >= fc {177- if a.Col < m.left {178- m.left = a.Col177+ if a.Col < g.left {178+ g.left = a.Col179179 }180- for a.Col >= m.left+m.visibleCols(m.left) {181- m.left++180+ for a.Col >= g.left+g.visibleCols(g.left) {181+ g.left++182182 }183183 }184- m.clampView()184+ g.clampView()185+}186+187+func (g *grid) visibleRows() int {188+ return max(g.height-gridTop-1, 1)189+}190+191+// visibleCols returns how many whole scrolling columns fit starting at192+// left.193+func (g *grid) visibleCols(left int) int {194+ n, w := 0, g.scrollX()195+ for c := left; c < sheet.MaxCols; c++ {196+ w += g.sheet.ColWidth(c)197+ if w > g.width {198+ break199+ }200+ n++201+ }202+ return max(n, 1)203+}204+205+// colSpan returns the visible column under x and the x where it starts:206+// a frozen column or a scrolling one, but not the divider between them.207+func (g *grid) colSpan(x int) (col, start int, ok bool) {208+ _, fc := g.frozen()209+ cx := rowHdrW210+ for c := 0; c < fc; c++ {211+ w := g.sheet.ColWidth(c)212+ if x >= cx && x < cx+w {213+ return c, cx, true214+ }215+ cx += w216+ }217+ cx = g.scrollX()218+ for c := g.left; c < sheet.MaxCols && cx < g.width; c++ {219+ w := g.sheet.ColWidth(c)220+ if x >= cx && x < cx+w {221+ return c, cx, true222+ }223+ cx += w224+ }225+ return 0, 0, false226+}227+228+// colStart returns the screen x where column c starts, which may be off229+// screen (or under the frozen columns, for a scrolling column left of230+// left).231+func (g *grid) colStart(c int) int {232+ _, fc := g.frozen()233+ if c < fc {234+ x := rowHdrW235+ for k := range c {236+ x += g.sheet.ColWidth(k)237+ }238+ return x239+ }240+ x := g.scrollX()241+ if c >= g.left {242+ for k := g.left; k < c; k++ {243+ x += g.sheet.ColWidth(k)244+ }245+ return x246+ }247+ for k := c; k < g.left; k++ {248+ x -= g.sheet.ColWidth(k)249+ }250+ return x251+}252+253+// cellPos returns the screen position of a visible cell's left edge.254+func (g *grid) cellPos(a sheet.Addr) (x, y int) {255+ y, _ = g.rowY(a.Row)256+ return g.colStart(a.Col), y257+}258+259+// clampBox keeps a w by h box at x, y on screen, shifting it left and up260+// as needed.261+func (g *grid) clampBox(x, y, w, h int) (int, int) {262+ return max(min(x, g.width-w), 0), max(min(y, g.height-h), 0)185263 }@@ -8,8 +8,8 @@ import (
88 "github.com/FelineStateMachine/012/internal/sheet"99 )1010 11-// Selection follows Google Sheets: the active cell (m.cur) stays put while12-// an extension corner (m.ext) moves with Shift+arrows, Shift+click or a11+// Selection follows Google Sheets: the active cell (cur) stays put while12+// an extension corner (ext) moves with Shift+arrows, Shift+click or a1313 // mouse drag. Clicking a column or row header selects whole columns or1414 // rows, and the corner selects everything.1515 @@ -37,12 +37,12 @@ const (
3737 3838 // selection returns the selected range; just the active cell when nothing3939 // is selected.40-func (m *Model) selection() sheet.Rect {41- if !m.selecting {42- return sheet.Rect{From: m.cur, To: m.cur}40+func (g *grid) selection() sheet.Rect {41+ if !g.selecting {42+ return sheet.Rect{From: g.cur, To: g.cur}4343 }44- r := sheet.NewRect(m.cur, m.ext)45- switch m.whole {44+ r := sheet.NewRect(g.cur, g.ext)45+ switch g.whole {4646 case wholeCols:4747 r.From.Row, r.To.Row = 0, sheet.MaxRows-14848 case wholeRows:@@ -54,14 +54,14 @@ func (m *Model) selection() sheet.Rect {
5454 }5555 5656 // hasRange reports whether more than one cell is selected.57-func (m *Model) hasRange() bool {58- r := m.selection()57+func (g *grid) hasRange() bool {58+ r := g.selection()5959 return r.From != r.To6060 }6161 62-func (m *Model) clearSelection() {63- m.selecting, m.whole = false, wholeNone64- m.ext = m.cur62+func (g *grid) clearSelection() {63+ g.selecting, g.whole = false, wholeNone64+ g.ext = g.cur6565 }6666 6767 // extendKey reports whether key is a Shift-modified movement key and@@ -76,16 +76,16 @@ func extendKey(key string) (string, bool) {
7676 7777 // moveKey handles a movement key in READY mode: plain keys move the active7878 // cell and drop the selection, Shift+keys extend it.79-func (m *Model) moveKey(key string) bool {79+func (g *grid) moveKey(key string) bool {8080 if base, ok := extendKey(key); ok {81- if !m.selecting {82- m.selecting, m.ext = true, m.cur81+ if !g.selecting {82+ g.selecting, g.ext = true, g.cur8383 }84- m.whole = wholeNone85- return m.navigate(base, &m.ext)84+ g.whole = wholeNone85+ return g.navigate(base, &g.ext)8686 }87- if m.navigate(key, &m.cur) {88- m.clearSelection()87+ if g.navigate(key, &g.cur) {88+ g.clearSelection()8989 return true9090 }9191 return false@@ -112,31 +112,100 @@ func (m *Model) pointMoveKey(key string) bool {
112112 }113113 114114 // selectColumns selects the columns spanned by the selection (Ctrl+Space).115-func (m *Model) selectColumns() tea.Cmd {116- if !m.selecting {117- m.ext = m.cur115+func (g *grid) selectColumns() tea.Cmd {116+ if !g.selecting {117+ g.ext = g.cur118118 }119- m.selecting, m.whole = true, wholeCols119+ g.selecting, g.whole = true, wholeCols120120 return nil121121 }122122 123123 // selectRows selects the rows spanned by the selection (Shift+Space).124-func (m *Model) selectRows() tea.Cmd {125- if !m.selecting {126- m.ext = m.cur124+func (g *grid) selectRows() tea.Cmd {125+ if !g.selecting {126+ g.ext = g.cur127127 }128- m.selecting, m.whole = true, wholeRows128+ g.selecting, g.whole = true, wholeRows129129 return nil130130 }131131 132-func (m *Model) selectAll() tea.Cmd {133- used, ok := m.sheet.UsedRange()134- if ok && !(m.selecting && m.whole == wholeNone && m.selection() == used) {135- m.cur, m.ext = used.From, used.To136- m.selecting, m.whole = true, wholeNone132+func (g *grid) selectAll() tea.Cmd {133+ used, ok := g.sheet.UsedRange()134+ if ok && !(g.selecting && g.whole == wholeNone && g.selection() == used) {135+ g.cur, g.ext = used.From, used.To136+ g.selecting, g.whole = true, wholeNone137137 return nil138138 }139- m.cur = sheet.Addr{Col: m.left, Row: m.top}140- m.selecting, m.whole, m.ext = true, wholeAll, m.cur139+ g.cur = sheet.Addr{Col: g.left, Row: g.top}140+ g.selecting, g.whole, g.ext = true, wholeAll, g.cur141141 return nil142142 }143+144+// navigate applies a movement key to a, returning false if it isn't one.145+// Keys follow Google Sheets.146+func (g *grid) navigate(key string, a *sheet.Addr) bool {147+ rows, cols := g.scrollRows(), g.visibleCols(g.left)148+ switch key {149+ case "up":150+ a.Row = g.stepRow(a.Row, -1)151+ case "down":152+ a.Row = g.stepRow(a.Row, 1)153+ case "left", "shift+tab":154+ a.Col--155+ case "right", "tab":156+ a.Col++157+ case "ctrl+up":158+ *a = g.sheet.Edge(*a, 0, -1)159+ case "ctrl+down":160+ *a = g.sheet.Edge(*a, 0, 1)161+ case "ctrl+left":162+ *a = g.sheet.Edge(*a, -1, 0)163+ case "ctrl+right", "end":164+ *a = g.sheet.Edge(*a, 1, 0)165+ case "pgup":166+ a.Row = g.stepRow(a.Row, -rows)167+ g.top = g.stepRow(g.top, -rows)168+ case "pgdown":169+ a.Row = g.stepRow(a.Row, rows)170+ g.top = g.stepRow(g.top, rows)171+ case "alt+pgup":172+ a.Col -= cols173+ g.left -= cols174+ case "alt+pgdown":175+ a.Col += cols176+ g.left += cols177+ case "home":178+ a.Col = 0179+ case "ctrl+home":180+ *a = sheet.Addr{}181+ case "ctrl+end":182+ used, _ := g.sheet.UsedRange()183+ *a = used.To184+ default:185+ return false186+ }187+ *a = clampAddr(*a)188+ a.Row = g.visibleRow(a.Row)189+ // Moving into the frozen panes by keyboard scrolls the rest back to190+ // the start, as in Sheets (Ctrl+Home shows A1 with row 2 under it).191+ if fr, fc := g.frozen(); a.Row < fr || a.Col < fc {192+ if a.Row < fr {193+ g.top = 0194+ }195+ if a.Col < fc {196+ g.left = 0197+ }198+ }199+ g.clampView()200+ return true201+}202+203+func isMoveKey(key string) bool {204+ switch key {205+ case "up", "down", "left", "right", "tab", "shift+tab", "pgup", "pgdown",206+ "alt+pgup", "alt+pgdown", "home", "end", "ctrl+home", "ctrl+end",207+ "ctrl+up", "ctrl+down", "ctrl+left", "ctrl+right":208+ return true209+ }210+ return false211+}@@ -84,15 +84,15 @@ func orderName(desc bool) string {
8484 // headerRows is how many rows at the top of r are headers that sorting8585 // leaves in place: frozen rows, the filter's header row, and, for a range8686 // found around the active cell, a first row of text over numbers.87-func (m *Model) headerRows(r sheet.Rect, selected bool) int {87+func (g *grid) headerRows(r sheet.Rect, selected bool) int {8888 n := 089- if fr, _ := m.sheet.Frozen(); fr > r.From.Row {89+ if fr, _ := g.sheet.Frozen(); fr > r.From.Row {9090 n = fr - r.From.Row9191 }92- if f, ok := m.sheet.FilterRange(); ok && f.From.Row == r.From.Row {92+ if f, ok := g.sheet.FilterRange(); ok && f.From.Row == r.From.Row {9393 n = max(n, 1)9494 }95- if n == 0 && !selected && m.looksLikeHeader(r) {95+ if n == 0 && !selected && g.looksLikeHeader(r) {9696 n = 19797 }9898 return min(n, r.To.Row-r.From.Row)@@ -100,13 +100,13 @@ func (m *Model) headerRows(r sheet.Rect, selected bool) int {
100100 101101 // looksLikeHeader reports whether r's first row is all text above a row102102 // with numbers, like "Item, Amount" above "Rent, 1450".103-func (m *Model) looksLikeHeader(r sheet.Rect) bool {103+func (g *grid) looksLikeHeader(r sheet.Rect) bool {104104 if r.To.Row == r.From.Row {105105 return false106106 }107107 text, numbers := false, false108108 for c := r.From.Col; c <= r.To.Col; c++ {109- switch m.sheet.Value(sheet.Addr{Col: c, Row: r.From.Row}).Kind {109+ switch g.sheet.Value(sheet.Addr{Col: c, Row: r.From.Row}).Kind {110110 case sheet.Empty:111111 continue112112 case sheet.Text:@@ -114,7 +114,7 @@ func (m *Model) looksLikeHeader(r sheet.Rect) bool {
114114 default:115115 return false116116 }117- switch m.sheet.Value(sheet.Addr{Col: c, Row: r.From.Row + 1}).Kind {117+ switch g.sheet.Value(sheet.Addr{Col: c, Row: r.From.Row + 1}).Kind {118118 case sheet.Number, sheet.Bool:119119 numbers = true120120 }@@ -91,16 +91,14 @@ var tabMenu = []menuItem{
9191 {cmd: "sheet.new"}, {cmd: "sheet.goto"},9292 }9393 94-func (m *Model) book() *sheet.Workbook { return m.sheet.Book() }95-9694 // sheetAt returns the sheet d tabs from the one shown, or nil past the9795 // ends: moving between sheets doesn't wrap around, as in Sheets.98-func (m *Model) sheetAt(d int) *sheet.Sheet {99- i := m.book().Index(m.sheet) + d100- if i < 0 || i >= m.book().Len() {96+func (g *grid) sheetAt(d int) *sheet.Sheet {97+ i := g.book().Index(g.sheet) + d98+ if i < 0 || i >= g.book().Len() {10199 return nil102100 }103- return m.book().Sheet(i)101+ return g.book().Sheet(i)104102 }105103 106104 func (m *Model) stepSheet(d int) tea.Cmd {