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 / tabstrip / tabstrip.go
tabstrip.go 6.5K
1// Package tabstrip is the sheet tabs at the left of the status line, the2// way tmux lists windows: the sheet shown is highlighted, a + adds a3// sheet, and when the tabs don't all fit, ‹ and › step through them. It4// lays the strip out and remembers where each sheet was left; what a5// click on it does is the UI's (showing, renaming, moving a sheet are6// commands). It needs no host: the UI hands it a View.7package tabstrip89import (10 "strings"1112 "charm.land/lipgloss/v2"13 "github.com/charmbracelet/x/ansi"1415 "src.rest/cat/012/internal/sheet"16 "src.rest/cat/012/internal/ui/theme"17)1819// Strip is the sheet tabs' state: where each sheet was left, and how far20// the strip is scrolled.21type Strip struct {22 Places map[*sheet.Sheet]Place // where each sheet's cursor and scroll were left23 left int // the first tab shown when they don't all fit24}2526// Place is where a sheet's cursor and scroll position were when it was27// last shown.28type Place struct {29 Cur sheet.Addr30 Top, Left int31}3233// Kind is a part of the strip.34type Kind int3536const (37 None Kind = iota38 Tab // a sheet's tab39 Add // the + after the tabs40 Prev // the ‹ before tabs scrolled off to the left41 Next // the › after tabs scrolled off to the right42)4344// View is what the strip shows: the sheets, the one shown, and what the45// mouse is over.46type View struct {47 Sheets []*sheet.Sheet48 Active int49 // Hover is the part under the mouse, and HoverIndex the tab for Tab.50 Hover Kind51 HoverIndex int52 // Busy is set while the mouse drags something other than a tab, so53 // passing over a tab doesn't highlight it.54 Busy bool55}5657// Span is a clickable part of the strip, at x on the status line.58type Span struct {59 Kind Kind60 Index int // the sheet, for Tab61 X, W int62}6364// maxName is how much of a long sheet name a tab shows.65const maxName = 206667// The widths of the strip's fixed parts: " + ", and "‹" or "›" with the68// space after it.69const addW, arrowW = 3, 27071// NotebookMark starts a notebook tab's label, so it reads apart from72// the sheets' without color.73const NotebookMark = "❯ "7475// SourceMark starts a linked source's tab's label; ! follows the name76// when its file can't be read.77const SourceMark = "▦ "7879// Label is a sheet's name as its tab shows it, a notebook's after its80// mark, with the mark of its linked regions, if it has any.81func Label(s *sheet.Sheet) string {82 label := " " + ansi.Truncate(s.Name(), maxName, "…") + " "83 if s.IsNotebook() {84 label = " " + NotebookMark + ansi.Truncate(s.Name(), maxName, "…") + " "85 }86 if info, ok := s.Source(); ok {87 label = " " + SourceMark + ansi.Truncate(s.Name(), maxName, "…") + " "88 if info.Err != "" {89 label += Mark(false, true) + " "90 }91 }92 if s.HasLinked() {93 label += liveMark(s) + " "94 }95 return label96}9798// Mark is the glyph that shows how a linked region follows its file:99// ● following, ‖ paused, ! when the file can't be read. It reads without100// color.101func Mark(paused, failed bool) string {102 switch {103 case failed:104 return "!"105 case paused:106 return "‖"107 }108 return "●"109}110111// liveMark is the mark of a sheet's linked regions: a failing one's112// first, then a following one's.113func liveMark(s *sheet.Sheet) string {114 paused := true115 for _, r := range s.LinkedRegions() {116 if r.Err != "" {117 return Mark(false, true)118 }119 paused = paused && r.Paused120 }121 return Mark(paused, false)122}123124// MinWidth is the narrowest the strip gets: the shown sheet's tab, the125// arrows and the +.126func (v View) MinWidth() int {127 w := ansi.StringWidth(Label(v.Sheets[v.Active])) + 4128 if len(v.Sheets) > 1 {129 w += 4130 }131 return w132}133134// FullWidth is how wide the strip is with every tab showing.135func (v View) FullWidth() int {136 w := addW137 for _, s := range v.Sheets {138 w += ansi.StringWidth(Label(s)) + 1139 }140 return w141}142143// style is the style of tab i: the one shown, one under the mouse (or144// where a dragged tab would go), or plain.145func (v View) style(th *theme.Theme, i int) lipgloss.Style {146 switch {147 case i == v.Active:148 return th.TabActive149 case v.Hover == Tab && v.HoverIndex == i && !v.Busy:150 return th.TabHover151 }152 return th.Tab153}154155// Layout lays out the tabs in room columns: as many as fit, always the156// shown one, the first shown staying put until the shown one would fall157// off, ‹ and › where tabs are hidden, then the +.158func (t *Strip) Layout(th *theme.Theme, v View, room int) (string, []Span) {159 widths := make([]int, len(v.Sheets))160 for i, s := range v.Sheets {161 widths[i] = ansi.StringWidth(Label(s))162 }163 first, last := t.scroll(widths, v.Active, room)164 var w writer165 if first > 0 {166 w.part(Prev, 0, th.Muted.Render("‹"))167 }168 for i := first; i <= last; i++ {169 label := Label(v.Sheets[i])170 if i == v.Active && w.x+ansi.StringWidth(label)+addW > room {171 label = ansi.Truncate(label, max(room-w.x-addW, 3), "…")172 }173 w.part(Tab, i, v.style(th, i).Render(label))174 }175 if last < len(v.Sheets)-1 {176 w.part(Next, 0, th.Muted.Render("›"))177 }178 addStyle := th.Muted179 if v.Hover == Add {180 addStyle = th.TabHover181 }182 w.part(Add, 0, addStyle.Render(" + "))183 return strings.TrimSuffix(w.b.String(), " "), w.spans184}185186// scroll picks the first and last tab shown, keeping the first where it187// was unless the active tab would fall off, and showing tabs to the left188// again when there's room, e.g. once the screen is wider.189func (t *Strip) scroll(widths []int, active, room int) (first, last int) {190 first = max(0, min(t.left, active))191 last, ok := fit(widths, first, active, room)192 for !ok && first < active {193 first++194 last, ok = fit(widths, first, active, room)195 }196 for first > 0 {197 l, ok := fit(widths, first-1, active, room)198 if !ok || l < last {199 break200 }201 first, last = first-1, l202 }203 t.left = first204 return first, max(last, active) // a name too long for the room is cut205}206207// fit returns the last tab that fits in room when the strip starts at208// tab first, and whether the active tab is among those shown.209func fit(widths []int, first, active, room int) (last int, ok bool) {210 w := addW211 if first > 0 {212 w += arrowW213 }214 last = first - 1215 for i := first; i < len(widths); i++ {216 more := 0217 if i < len(widths)-1 {218 more = arrowW219 }220 if w+widths[i]+1+more > room {221 break222 }223 w += widths[i] + 1224 last = i225 }226 return last, last >= active227}228229// writer writes the parts of the strip, noting where each is.230type writer struct {231 b strings.Builder232 spans []Span233 x int234}235236func (w *writer) part(kind Kind, index int, text string) {237 width := ansi.StringWidth(text)238 w.spans = append(w.spans, Span{Kind: kind, Index: index, X: w.x, W: width})239 w.b.WriteString(text + " ")240 w.x += width + 1241}