Laying out the sheet tabs and remembering where each sheet was left need no host: the model hands the strip a View (the sheets, the one shown, what the mouse is over) and maps its spans to hits. What clicks on it do stays in package ui, since they're commands.
- commit
- 5f3f9f54ca8800b7c219e397bfc37137904d2112
- parent
- d6549fc7eef9c4e6a84904260f5e404b4db2b401
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 3 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 11 | +- |
| M | internal/ | 29 | ++-- |
| M | internal/ | 5 | +- |
| M | internal/ | 192 | +++--------------------------- |
| A | internal/ | 193 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 91 | ++++++++++++++ |
@@ -15,6 +15,7 @@ import (
1515 "github.com/FelineStateMachine/012/internal/ui/findbar"1616 "github.com/FelineStateMachine/012/internal/ui/lineedit"1717 "github.com/FelineStateMachine/012/internal/ui/overlay"18+ "github.com/FelineStateMachine/012/internal/ui/tabstrip"1819 "github.com/FelineStateMachine/012/internal/ui/theme"1920 )2021 @@ -92,7 +93,7 @@ type Model struct {
9293 prompt *prompt // a question on the context line: prompt.go9394 overlay overlay.Overlay // the open menu, picker or bar, if any (modeMenu): overlay.go9495 mouse mouseState // drags, hover and double clicks: mouse.go95- tabs tabStrip // the sheet tabs and where each sheet was left: tabstrip.go96+ tabs tabstrip.Strip // the sheet tabs and where each sheet was left: tabstrip.go9697 find *findbar.Bar // the last search, reopened by Ctrl+F: find.go9798 charts chartState // chart commands' target: charts.go9899 copied clipboard // what Ctrl+V pastes: clipboard.go@@ -113,8 +113,8 @@ func (m *Model) hitTest(x, y int) hit {
113113 }114114 return hit{kind: hitCell, addr: a}115115 case y == m.height-1:116- if sp, ok := m.tabAt(x); ok {117- return hit{kind: sp.kind, addr: sheet.Addr{Col: sp.index}}116+ if h, ok := m.tabAt(x); ok {117+ return h118118 }119119 return hit{kind: hitStatus}120120 }@@ -15,6 +15,7 @@ import (
1515 "github.com/FelineStateMachine/012/internal/sheet"1616 "github.com/FelineStateMachine/012/internal/telemetry"1717 "github.com/FelineStateMachine/012/internal/ui/overlay"18+ "github.com/FelineStateMachine/012/internal/ui/tabstrip"1819 "github.com/FelineStateMachine/012/internal/ui/theme"1920 )2021 @@ -281,7 +282,7 @@ func (m *Model) statusLine() string {
281282 // statistics or the ways in to everything else on the right. While a282283 // menu, picker or suggestion list is open, it says what the highlighted283284 // item does instead.284-func (m *Model) statusLayout() (string, []tabSpan) {285+func (m *Model) statusLayout() (string, []tabstrip.Span) {285286 if m.mode == modeError {286287 return m.th.Error.Render(m.errMsg) + m.th.Muted.Render(" press any key"), nil287288 }@@ -315,12 +316,12 @@ func (m *Model) floatingStatus() (string, bool) {
315316 // out, the right side gives up detail first, then the file name, then316317 // tabs scroll: first every tab is tried, then half the line of them, then317318 // just the one shown.318-func (m *Model) fileStatus() (string, []tabSpan) {319+func (m *Model) fileStatus() (string, []tabstrip.Span) {319320 v := m.tabView()320321 state := m.statusState()321322 infos := []string{m.displayName() + state, strings.TrimPrefix(state, " ")}322323 rights := m.statusRights()323- for _, need := range []int{v.fullWidth(), min(v.fullWidth(), m.width/2), v.minWidth()} {324+ for _, need := range []int{v.FullWidth(), min(v.FullWidth(), m.width/2), v.MinWidth()} {324325 for _, info := range infos {325326 if info != "" {326327 info = m.th.FrozenLine.Render(" │ ") + info // like a tmux pane border@@ -335,7 +336,7 @@ func (m *Model) fileStatus() (string, []tabSpan) {
335336 336337 // statusFits lays out the status line with info after the tabs and the337338 // most detailed of rights that leaves the tabs need columns.338-func (m *Model) statusFits(v tabView, need int, info string, rights []string) (string, []tabSpan, bool) {339+func (m *Model) statusFits(v tabstrip.View, need int, info string, rights []string) (string, []tabstrip.Span, bool) {339340 for _, right := range rights {340341 room := m.width - ansi.StringWidth(info)341342 if right != "" {@@ -344,7 +345,7 @@ func (m *Model) statusFits(v tabView, need int, info string, rights []string) (s
344345 if room < need && (right != "" || info != "") {345346 continue346347 }347- tabs, spans := m.tabs.layout(&m.th, v, room)348+ tabs, spans := m.tabs.Layout(&m.th, v, room)348349 left := tabs + info349350 gap := max(m.width-ansi.StringWidth(left)-ansi.StringWidth(right), 1)350351 return left + strings.Repeat(" ", gap) + right, spans, true@@ -5,6 +5,7 @@ import (
55 66 "github.com/FelineStateMachine/012/internal/sheet"77 "github.com/FelineStateMachine/012/internal/ui/picker"8+ "github.com/FelineStateMachine/012/internal/ui/tabstrip"89 )910 1011 // Sheets work as in Google Sheets: Ctrl+PgDn and Ctrl+PgUp (or Alt+Right@@ -128,8 +129,8 @@ func (m *Model) showSheet(s *sheet.Sheet) {
128129 }129130 m.leave()130131 m.sheet = s131- p := m.tabs.places[s]132- m.cur, m.top, m.left = p.cur, p.top, p.left132+ p := m.tabs.Places[s]133+ m.cur, m.top, m.left = p.Cur, p.Top, p.Left133134 m.clearSelection()134135 m.charts.last = -1135136 m.book().SetActive(s)@@ -137,14 +138,14 @@ func (m *Model) showSheet(s *sheet.Sheet) {
137138 138139 // leave remembers where the sheet shown is, before another is shown.139140 func (m *Model) leave() {140- if m.tabs.places == nil {141- m.tabs.places = map[*sheet.Sheet]place{}141+ if m.tabs.Places == nil {142+ m.tabs.Places = map[*sheet.Sheet]tabstrip.Place{}142143 }143144 cur := m.cur144145 if m.away() {145146 cur = m.point.at // the entry's cell is on another sheet146147 }147- m.tabs.places[m.sheet] = place{cur: cur, top: m.top, left: m.left}148+ m.tabs.Places[m.sheet] = tabstrip.Place{Cur: cur, Top: m.top, Left: m.left}148149 }149150 150151 // away reports whether an entry is being typed for a cell on another@@ -184,13 +185,13 @@ func (m *Model) pointInto(s *sheet.Sheet) {
184185 cur := m.cur185186 m.leave()186187 m.sheet = s187- p := m.tabs.places[s]188+ p := m.tabs.Places[s]188189 if s == m.entry.home {189- p.cur = cur // back home, pointing starts at the entry's cell190+ p.Cur = cur // back home, pointing starts at the entry's cell190191 }191- m.top, m.left = p.top, p.left192+ m.top, m.left = p.Top, p.Left192193 m.cur = cur193- m.point = pointer{at: p.cur}194+ m.point = pointer{at: p.Cur}194195 m.book().SetActive(s)195196 }196197 @@ -203,10 +204,10 @@ func (m *Model) returnHome() {
203204 return204205 }205206 cur := m.cur206- m.tabs.places[m.sheet] = place{cur: m.point.at, top: m.top, left: m.left}207+ m.tabs.Places[m.sheet] = tabstrip.Place{Cur: m.point.at, Top: m.top, Left: m.left}207208 m.sheet = home208- p := m.tabs.places[home]209- m.cur, m.top, m.left = cur, p.top, p.left209+ p := m.tabs.Places[home]210+ m.cur, m.top, m.left = cur, p.Top, p.Left210211 m.book().SetActive(home)211212 }212213 @@ -229,8 +230,8 @@ func (m *Model) afterSheetsChange(prefer *sheet.Sheet, index int) {
229230 case !m.sheet.Live():230231 s := m.nearVisible(clamp(index, 0, book.Len()-1))231232 m.sheet = s // the old sheet is gone: nothing to remember of it232- p := m.tabs.places[s]233- m.cur, m.top, m.left = p.cur, p.top, p.left233+ p := m.tabs.Places[s]234+ m.cur, m.top, m.left = p.Cur, p.Top, p.Left234235 m.clearSelection()235236 m.charts.last = -1236237 book.SetActive(s)@@ -8,6 +8,7 @@ import (
88 "github.com/charmbracelet/x/ansi"99 1010 "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/tabstrip"1112 )1213 1314 // status is the status line, where the tabs are.@@ -19,8 +20,8 @@ func tabX(t *testing.T, m *Model, name string) int {
1920 t.Helper()2021 _, spans := m.statusLayout()2122 for _, sp := range spans {22- if name == "+" && sp.kind == hitTabAdd || sp.kind == hitTab && m.tabSheet(sp.index).Name() == name {23- return sp.x + sp.w/223+ if name == "+" && sp.Kind == tabstrip.Add || sp.Kind == tabstrip.Tab && m.tabSheet(sp.Index).Name() == name {24+ return sp.X + sp.W/22425 }2526 }2627 t.Fatalf("no tab %q in %q", name, status(m))@@ -3,198 +3,44 @@ package ui
33 import (44 "slices"55 "strconv"6- "strings"76 87 tea "charm.land/bubbletea/v2"9- "charm.land/lipgloss/v2"10- "github.com/charmbracelet/x/ansi"118 129 "github.com/FelineStateMachine/012/internal/macro"1310 "github.com/FelineStateMachine/012/internal/sheet"14- "github.com/FelineStateMachine/012/internal/ui/theme"11+ "github.com/FelineStateMachine/012/internal/ui/tabstrip"1512 )1613 17-// The tab strip at the left of the status line, the way tmux lists18-// windows: the sheet shown is highlighted, a + adds a sheet, and when19-// the tabs don't all fit, ‹ and › step through them. Clicking a tab shows20-// it, double-clicking renames it, right-clicking opens its menu, and21-// dragging it moves it.22-23-// tabStrip is the sheet tabs' state: where each sheet was left, and how24-// far the strip is scrolled.25-type tabStrip struct {26- places map[*sheet.Sheet]place // where each sheet's cursor and scroll were left27- left int // the first tab shown when they don't all fit28-}29-30-// place is where a sheet's cursor and scroll position were when it was31-// last shown.32-type place struct {33- cur sheet.Addr34- top, left int35-}36-37-// maxTabName is how much of a long sheet name a tab shows.38-const maxTabName = 2039-40-// The widths of the strip's fixed parts: " + ", and "‹" or "›" with the41-// space after it.42-const tabAddW, tabArrowW = 3, 214+// The sheet tabs (package tabstrip) as the model shows them and what15+// clicking them does: clicking a tab shows it, double-clicking renames16+// it, right-clicking opens its menu, and dragging it moves it.4317 4418 // tabView is what the tab strip shows: the sheets, the one shown, and4519 // what the mouse is over or dragging.46-type tabView struct {47- sheets []*sheet.Sheet48- active int49- hover hit50- drag dragKind51-}52-53-func (m *Model) tabView() tabView {20+func (m *Model) tabView() tabstrip.View {5421 sheets := m.visibleSheets()55- return tabView{sheets: sheets, active: slices.Index(sheets, m.sheet), hover: m.mouse.hover, drag: m.mouse.drag}56-}57-58-// tabSpan is a clickable part of the tab strip on the status line.59-type tabSpan struct {60- kind hitKind // hitTab, hitTabAdd, hitTabPrev or hitTabNext61- index int // the sheet, for hitTab62- x, w int63-}64-65-// tabLabel is a sheet's name as its tab shows it.66-func tabLabel(s *sheet.Sheet) string {67- return " " + ansi.Truncate(s.Name(), maxTabName, "…") + " "68-}69-70-// minWidth is the narrowest the tab strip gets: the shown sheet's tab,71-// the arrows and the +.72-func (v tabView) minWidth() int {73- w := ansi.StringWidth(tabLabel(v.sheets[v.active])) + 474- if len(v.sheets) > 1 {75- w += 476- }77- return w78-}79-80-// fullWidth is how wide the tab strip is with every tab showing.81-func (v tabView) fullWidth() int {82- w := tabAddW83- for _, s := range v.sheets {84- w += ansi.StringWidth(tabLabel(s)) + 185- }86- return w87-}88-89-// style is the style of tab i: the one shown, one under the mouse (or90-// where a dragged tab would go), or plain.91-func (v tabView) style(th *theme.Theme, i int) lipgloss.Style {92- switch {93- case i == v.active:94- return th.TabActive95- case v.hover.kind == hitTab && v.hover.addr.Col == i && (v.drag == dragNone || v.drag == dragTab):96- return th.TabHover97- }98- return th.Tab99-}100-101-// layout lays out the tabs in room columns: as many as fit, always the102-// shown one, the first shown staying put until the shown one would fall103-// off (t.left), ‹ and › where tabs are hidden, then the +.104-func (t *tabStrip) layout(th *theme.Theme, v tabView, room int) (string, []tabSpan) {105- widths := make([]int, len(v.sheets))106- for i, s := range v.sheets {107- widths[i] = ansi.StringWidth(tabLabel(s))108- }109- first, last := t.scroll(widths, v.active, room)110- var w tabWriter111- if first > 0 {112- w.part(hitTabPrev, 0, th.Muted.Render("‹"))113- }114- for i := first; i <= last; i++ {115- label := tabLabel(v.sheets[i])116- if i == v.active && w.x+ansi.StringWidth(label)+tabAddW > room {117- label = ansi.Truncate(label, max(room-w.x-tabAddW, 3), "…")118- }119- w.part(hitTab, i, v.style(th, i).Render(label))120- }121- if last < len(v.sheets)-1 {122- w.part(hitTabNext, 0, th.Muted.Render("›"))123- }124- addStyle := th.Muted125- if v.hover.kind == hitTabAdd {126- addStyle = th.TabHover127- }128- w.part(hitTabAdd, 0, addStyle.Render(" + "))129- return strings.TrimSuffix(w.b.String(), " "), w.spans130-}131-132-// scroll picks the first and last tab shown, keeping the first where it133-// was unless the active tab would fall off, and showing tabs to the left134-// again when there's room, e.g. once the screen is wider.135-func (t *tabStrip) scroll(widths []int, active, room int) (first, last int) {136- first = clamp(t.left, 0, active)137- last, ok := tabsFit(widths, first, active, room)138- for !ok && first < active {139- first++140- last, ok = tabsFit(widths, first, active, room)141- }142- for first > 0 {143- l, ok := tabsFit(widths, first-1, active, room)144- if !ok || l < last {145- break146- }147- first, last = first-1, l148- }149- t.left = first150- return first, max(last, active) // a name too long for the room is cut151-}152-153-// tabsFit returns the last tab that fits in room when the strip starts154-// at tab first, and whether the active tab is among those shown.155-func tabsFit(widths []int, first, active, room int) (last int, ok bool) {156- w := tabAddW157- if first > 0 {158- w += tabArrowW159- }160- last = first - 1161- for i := first; i < len(widths); i++ {162- more := 0163- if i < len(widths)-1 {164- more = tabArrowW165- }166- if w+widths[i]+1+more > room {167- break168- }169- w += widths[i] + 1170- last = i22+ v := tabstrip.View{Sheets: sheets, Active: slices.Index(sheets, m.sheet), Busy: m.mouse.drag != dragNone && m.mouse.drag != dragTab}23+ switch m.mouse.hover.kind {24+ case hitTab:25+ v.Hover, v.HoverIndex = tabstrip.Tab, m.mouse.hover.addr.Col26+ case hitTabAdd:27+ v.Hover = tabstrip.Add17128 }172- return last, last >= active29+ return v17330 }17431 175-// tabWriter writes the parts of the tab strip, noting where each is.176-type tabWriter struct {177- b strings.Builder178- spans []tabSpan179- x int180-}181-182-func (w *tabWriter) part(kind hitKind, index int, text string) {183- width := ansi.StringWidth(text)184- w.spans = append(w.spans, tabSpan{kind: kind, index: index, x: w.x, w: width})185- w.b.WriteString(text + " ")186- w.x += width + 1187-}32+// tabHits are the hits of the parts of the tab strip.33+var tabHits = map[tabstrip.Kind]hitKind{tabstrip.Tab: hitTab, tabstrip.Add: hitTabAdd, tabstrip.Prev: hitTabPrev, tabstrip.Next: hitTabNext}18834 189-// tabAt returns the part of the tab strip at x on the status line.190-func (m *Model) tabAt(x int) (tabSpan, bool) {35+// tabAt returns what part of the tab strip is at x on the status line.36+func (m *Model) tabAt(x int) (hit, bool) {19137 _, spans := m.statusLayout()19238 for _, sp := range spans {193- if x >= sp.x && x < sp.x+sp.w {194- return sp, true39+ if x >= sp.X && x < sp.X+sp.W {40+ return hit{kind: tabHits[sp.Kind], addr: sheet.Addr{Col: sp.Index}}, true19541 }19642 }197- return tabSpan{}, false43+ return hit{}, false19844 }19945 20046 // tabPress handles a click on the tab strip: show the tab (renaming it on@@ -0,0 +1,193 @@
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.7+package tabstrip8+9+import (10+ "strings"11+12+ "charm.land/lipgloss/v2"13+ "github.com/charmbracelet/x/ansi"14+15+ "github.com/FelineStateMachine/012/internal/sheet"16+ "github.com/FelineStateMachine/012/internal/ui/theme"17+)18+19+// Strip is the sheet tabs' state: where each sheet was left, and how far20+// the strip is scrolled.21+type 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+}25+26+// Place is where a sheet's cursor and scroll position were when it was27+// last shown.28+type Place struct {29+ Cur sheet.Addr30+ Top, Left int31+}32+33+// Kind is a part of the strip.34+type Kind int35+36+const (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+)43+44+// View is what the strip shows: the sheets, the one shown, and what the45+// mouse is over.46+type 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+}56+57+// Span is a clickable part of the strip, at x on the status line.58+type Span struct {59+ Kind Kind60+ Index int // the sheet, for Tab61+ X, W int62+}63+64+// maxName is how much of a long sheet name a tab shows.65+const maxName = 2066+67+// The widths of the strip's fixed parts: " + ", and "‹" or "›" with the68+// space after it.69+const addW, arrowW = 3, 270+71+// Label is a sheet's name as its tab shows it.72+func Label(s *sheet.Sheet) string {73+ return " " + ansi.Truncate(s.Name(), maxName, "…") + " "74+}75+76+// MinWidth is the narrowest the strip gets: the shown sheet's tab, the77+// arrows and the +.78+func (v View) MinWidth() int {79+ w := ansi.StringWidth(Label(v.Sheets[v.Active])) + 480+ if len(v.Sheets) > 1 {81+ w += 482+ }83+ return w84+}85+86+// FullWidth is how wide the strip is with every tab showing.87+func (v View) FullWidth() int {88+ w := addW89+ for _, s := range v.Sheets {90+ w += ansi.StringWidth(Label(s)) + 191+ }92+ return w93+}94+95+// style is the style of tab i: the one shown, one under the mouse (or96+// where a dragged tab would go), or plain.97+func (v View) style(th *theme.Theme, i int) lipgloss.Style {98+ switch {99+ case i == v.Active:100+ return th.TabActive101+ case v.Hover == Tab && v.HoverIndex == i && !v.Busy:102+ return th.TabHover103+ }104+ return th.Tab105+}106+107+// Layout lays out the tabs in room columns: as many as fit, always the108+// shown one, the first shown staying put until the shown one would fall109+// off, ‹ and › where tabs are hidden, then the +.110+func (t *Strip) Layout(th *theme.Theme, v View, room int) (string, []Span) {111+ widths := make([]int, len(v.Sheets))112+ for i, s := range v.Sheets {113+ widths[i] = ansi.StringWidth(Label(s))114+ }115+ first, last := t.scroll(widths, v.Active, room)116+ var w writer117+ if first > 0 {118+ w.part(Prev, 0, th.Muted.Render("‹"))119+ }120+ for i := first; i <= last; i++ {121+ label := Label(v.Sheets[i])122+ if i == v.Active && w.x+ansi.StringWidth(label)+addW > room {123+ label = ansi.Truncate(label, max(room-w.x-addW, 3), "…")124+ }125+ w.part(Tab, i, v.style(th, i).Render(label))126+ }127+ if last < len(v.Sheets)-1 {128+ w.part(Next, 0, th.Muted.Render("›"))129+ }130+ addStyle := th.Muted131+ if v.Hover == Add {132+ addStyle = th.TabHover133+ }134+ w.part(Add, 0, addStyle.Render(" + "))135+ return strings.TrimSuffix(w.b.String(), " "), w.spans136+}137+138+// scroll picks the first and last tab shown, keeping the first where it139+// was unless the active tab would fall off, and showing tabs to the left140+// again when there's room, e.g. once the screen is wider.141+func (t *Strip) scroll(widths []int, active, room int) (first, last int) {142+ first = max(0, min(t.left, active))143+ last, ok := fit(widths, first, active, room)144+ for !ok && first < active {145+ first++146+ last, ok = fit(widths, first, active, room)147+ }148+ for first > 0 {149+ l, ok := fit(widths, first-1, active, room)150+ if !ok || l < last {151+ break152+ }153+ first, last = first-1, l154+ }155+ t.left = first156+ return first, max(last, active) // a name too long for the room is cut157+}158+159+// fit returns the last tab that fits in room when the strip starts at160+// tab first, and whether the active tab is among those shown.161+func fit(widths []int, first, active, room int) (last int, ok bool) {162+ w := addW163+ if first > 0 {164+ w += arrowW165+ }166+ last = first - 1167+ for i := first; i < len(widths); i++ {168+ more := 0169+ if i < len(widths)-1 {170+ more = arrowW171+ }172+ if w+widths[i]+1+more > room {173+ break174+ }175+ w += widths[i] + 1176+ last = i177+ }178+ return last, last >= active179+}180+181+// writer writes the parts of the strip, noting where each is.182+type writer struct {183+ b strings.Builder184+ spans []Span185+ x int186+}187+188+func (w *writer) part(kind Kind, index int, text string) {189+ width := ansi.StringWidth(text)190+ w.spans = append(w.spans, Span{Kind: kind, Index: index, X: w.x, W: width})191+ w.b.WriteString(text + " ")192+ w.x += width + 1193+}@@ -0,0 +1,91 @@
1+package tabstrip2+3+import (4+ "strconv"5+ "strings"6+ "testing"7+8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/theme"12+)13+14+// book returns a workbook of n sheets named Sheet1, Sheet2, ...15+func book(t *testing.T, n int) []*sheet.Sheet {16+ t.Helper()17+ s := sheet.New()18+ for i := 2; i <= n; i++ {19+ if _, err := s.Book().AddSheet("Sheet"+strconv.Itoa(i), i-1); err != nil {20+ t.Fatal(err)21+ }22+ }23+ return s.Book().Sheets()24+}25+26+func kinds(spans []Span) string {27+ names := map[Kind]string{Tab: "tab", Add: "+", Prev: "‹", Next: "›"}28+ var out []string29+ for _, sp := range spans {30+ out = append(out, names[sp.Kind])31+ }32+ return strings.Join(out, " ")33+}34+35+func TestEveryTabFits(t *testing.T) {36+ th := theme.New(true)37+ var s Strip38+ v := View{Sheets: book(t, 3), Active: 1}39+ line, spans := s.Layout(&th, v, 80)40+ if got := ansi.Strip(line); got != " Sheet1 Sheet2 Sheet3 + " {41+ t.Errorf("strip %q", got)42+ }43+ if kinds(spans) != "tab tab tab +" || ansi.StringWidth(line) != v.FullWidth() {44+ t.Errorf("spans %s, width %d of %d", kinds(spans), ansi.StringWidth(line), v.FullWidth())45+ }46+ for _, sp := range spans {47+ if got := ansi.Strip(line)[sp.X : sp.X+sp.W]; sp.Kind == Tab && got != Label(v.Sheets[sp.Index]) {48+ t.Errorf("span %+v covers %q", sp, got)49+ }50+ }51+}52+53+func TestScrollKeepsTheActiveTab(t *testing.T) {54+ th := theme.New(true)55+ var s Strip56+ v := View{Sheets: book(t, 8), Active: 0}57+ room := v.MinWidth() + 1258+ _, spans := s.Layout(&th, v, room)59+ if k := kinds(spans); !strings.HasPrefix(k, "tab") || !strings.Contains(k, "›") {60+ t.Errorf("first tab shown: %s", k)61+ }62+ v.Active = 763+ line, spans := s.Layout(&th, v, room)64+ if k := kinds(spans); !strings.HasPrefix(k, "‹") || strings.Contains(k, "›") {65+ t.Errorf("last tab shown: %s", k)66+ }67+ if !strings.Contains(ansi.Strip(line), "Sheet8") || ansi.StringWidth(line) > room {68+ t.Errorf("strip %q in %d", ansi.Strip(line), room)69+ }70+ // Going back one tab keeps the strip where it is.71+ v.Active = 672+ again, _ := s.Layout(&th, v, room)73+ if ansi.Strip(again) != ansi.Strip(line) {74+ t.Errorf("strip moved: %q then %q", ansi.Strip(line), ansi.Strip(again))75+ }76+}77+78+func TestHoverStyles(t *testing.T) {79+ th := theme.New(true)80+ var s Strip81+ v := View{Sheets: book(t, 2), Active: 0, Hover: Tab, HoverIndex: 1}82+ hovered, _ := s.Layout(&th, v, 80)83+ v.Busy = true84+ busy, _ := s.Layout(&th, v, 80)85+ if hovered == busy {86+ t.Error("hovering a tab looks the same while dragging something else")87+ }88+ if want := th.TabHover.Render(Label(v.Sheets[1])); !strings.Contains(hovered, want) {89+ t.Errorf("hovered tab not highlighted: %q", hovered)90+ }91+}