cat / 012
clone

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

Move the tab strip's layout into internal/ui/tabstrip

Dami committed 5f3f9f5
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>
8 files changed, +331 -197
Minternal/ui/model.go3+-
Minternal/ui/mouse.go4+-
Minternal/ui/panel.go11+-
Minternal/ui/tabs.go29++--
Minternal/ui/tabs_test.go5+-
Minternal/ui/tabstrip.go192+++---------------------------
Ainternal/ui/tabstrip/tabstrip.go193++++++++++++++++++++++++++++++
Ainternal/ui/tabstrip/tabstrip_test.go91++++++++++++++
M internal/ui/model.go +2 -1
M internal/ui/mouse.go +2 -2
M internal/ui/panel.go +6 -5
@@ -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
M internal/ui/tabs.go +15 -14
@@ -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)
M internal/ui/tabs_test.go +3 -2
M internal/ui/tabstrip.go +19 -173
@@ -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
A internal/ui/tabstrip/tabstrip.go +193 -0
@@ -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+}
A internal/ui/tabstrip/tabstrip_test.go +91 -0
@@ -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+}