tabstrip.go holds the tab strip: its state (tabStrip), a tabView of the sheets and the mouse, the layout (cognitive complexity 29, now split into choosing the tabs shown and writing them) and its mouse handling. statusLayout (29) splits into the overlay's status and fitting the file and statistics beside the tabs. Stepping through tabs, renaming by double click and switching sheets while typing go through the sheet.prev, sheet.next and sheet.rename commands.
- commit
- 4254f682f48d32a3700079238c7dc7c92f64ba9f
- parent
- 793815b5b35bb4e1377247d3a4eb005c4ed7893b
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 83 | ++++++---- |
| M | internal/ | 235 | +--------------------------- |
| A | internal/ | 248 | ++++++++++++++++++++++++++++++ |
@@ -224,10 +224,10 @@ func (m *Model) statusLine() string {
224224 }225225 226226 // statusLayout is the status line and where its sheet tabs are. From the227-// left: the tabs, a divider, the file name and its state, then selection statistics228-// or the ways in to everything else on the right. When space runs out,229-// the right side gives up detail first, then the file name, then tabs230-// scroll.227+// left: the tabs, a divider, the file name and its state, then selection228+// statistics or the ways in to everything else on the right. While a229+// menu, picker or suggestion list is open, it says what the highlighted230+// item does instead.231231 func (m *Model) statusLayout() (string, []tabSpan) {232232 if m.mode == modeError {233233 return m.th.Error.Render(m.errMsg) + m.th.Muted.Render(" press any key"), nil@@ -235,47 +235,70 @@ func (m *Model) statusLayout() (string, []tabSpan) {
235235 if m.xfer.job != nil {236236 return m.importStatus(), nil237237 }238+ if line, ok := m.floatingStatus(); ok {239+ return line, nil240+ }241+ return m.fileStatus()242+}243+244+// floatingStatus is what the highlighted item of the open overlay or the245+// formula suggestions does, and the keys that apply.246+func (m *Model) floatingStatus() (string, bool) {238247 desc, keys, floating := m.assistStatus()239248 if m.overlay != nil {240- desc, keys, floating = "", "", true241249 desc, keys = m.overlay.status(m)250+ floating = true242251 }243- if floating {244- // What the highlighted item does and the keys that apply.245- if desc != "" || keys != "" {246- if room := m.width - ansi.StringWidth(keys) - 3; room >= 12 {247- desc = ansi.Truncate(desc, room, "…")248- }249- return m.spread(desc, keys), nil250- }252+ if !floating || desc == "" && keys == "" {253+ return "", false254+ }255+ if room := m.width - ansi.StringWidth(keys) - 3; room >= 12 {256+ desc = ansi.Truncate(desc, room, "…")251257 }252- name, state := m.displayName(), m.statusState()253- infos := []string{name + state, strings.TrimPrefix(state, " ")}254- // First try to show every tab, then half the line of them, then just255- // the one shown.256- for _, need := range []int{m.allTabs(), min(m.allTabs(), m.width/2), m.minTabs()} {258+ return m.spread(desc, keys), true259+}260+261+// fileStatus is the tabs, the file and the right side. When space runs262+// out, the right side gives up detail first, then the file name, then263+// tabs scroll: first every tab is tried, then half the line of them, then264+// just the one shown.265+func (m *Model) fileStatus() (string, []tabSpan) {266+ v := m.tabView()267+ state := m.statusState()268+ infos := []string{m.displayName() + state, strings.TrimPrefix(state, " ")}269+ rights := m.statusRights()270+ for _, need := range []int{v.fullWidth(), min(v.fullWidth(), m.width/2), v.minWidth()} {257271 for _, info := range infos {258272 if info != "" {259273 info = m.th.FrozenLine.Render(" │ ") + info // like a tmux pane border260274 }261- for _, right := range m.statusRights() {262- room := m.width - ansi.StringWidth(info)263- if right != "" {264- room -= ansi.StringWidth(right) + 3265- }266- if room < need && (right != "" || info != "") {267- continue268- }269- tabs, spans := m.tabStrip(room)270- left := tabs + info271- gap := max(m.width-ansi.StringWidth(left)-ansi.StringWidth(right), 1)272- return left + strings.Repeat(" ", gap) + right, spans275+ if line, spans, ok := m.statusFits(v, need, info, rights); ok {276+ return line, spans273277 }274278 }275279 }276280 return "", nil // not reached: the last choice has no right side or info277281 }278282 283+// statusFits lays out the status line with info after the tabs and the284+// most detailed of rights that leaves the tabs need columns.285+func (m *Model) statusFits(v tabView, need int, info string, rights []string) (string, []tabSpan, bool) {286+ for _, right := range rights {287+ room := m.width - ansi.StringWidth(info)288+ if right != "" {289+ room -= ansi.StringWidth(right) + 3290+ }291+ if room < need && (right != "" || info != "") {292+ continue293+ }294+ tabs, spans := m.tabs.layout(&m.th, v, room)295+ left := tabs + info296+ gap := max(m.width-ansi.StringWidth(left)-ansi.StringWidth(right), 1)297+ return left + strings.Repeat(" ", gap) + right, spans, true298+ }299+ return "", nil, false300+}301+279302 // statusState is what the status line says about the file after its280303 // name: modified, decimal arithmetic, a circular reference, rows hidden281304 // by the filter, JEV at work. Each part starts with two spaces.@@ -1,11 +1,7 @@
11 package ui22 33 import (4- "strconv"5- "strings"6-74 tea "charm.land/bubbletea/v2"8- "github.com/charmbracelet/x/ansi"95 106 "github.com/FelineStateMachine/012/internal/sheet"117 )@@ -14,33 +10,13 @@ import (
1410 // and Alt+Left, for terminals that keep Ctrl+PgUp/PgDn for their own1511 // tabs) move between sheets, Shift+F11 adds one, and each sheet remembers1612 // its cursor and scroll position. The tabs sit at the left of the status17-// line, the way tmux lists windows: the sheet shown is highlighted, a +18-// adds a sheet, and when they don't all fit, ‹ and › step through them.19-// Clicking a tab shows it, double-clicking renames it, right-clicking20-// opens its menu, and dragging it moves it.13+// line (tabstrip.go).2114 //2215 // While a formula is being typed, switching sheets points into the other2316 // sheet, as clicking a tab does in Sheets: the entry stays with its cell2417 // (entry.home), and the pointer inserts references such as Sheet2!A1.2518 // Enter stores the formula and returns to its sheet.2619 27-// tabStrip is the sheet tabs' state: where each sheet was left, and how28-// far the strip is scrolled.29-type tabStrip struct {30- places map[*sheet.Sheet]place // where each sheet's cursor and scroll were left31- left int // the first tab shown when they don't all fit32-}33-34-// place is where a sheet's cursor and scroll position were when it was35-// last shown.36-type place struct {37- cur sheet.Addr38- top, left int39-}40-41-// maxTabName is how much of a long sheet name a tab shows.42-const maxTabName = 2043-4420 func init() {4521 register(4622 &command{id: "sheet.new", title: "New sheet", desc: "Add an empty sheet after the others", run: func(m *Model) tea.Cmd {@@ -322,208 +298,13 @@ func (m *Model) openSheetPicker() {
322298 m.openOverlay(p)323299 }324300 325-// sheetKey handles the keys that switch sheets while an entry is typed.301+// sheetKey runs the commands that switch sheets while an entry is typed,302+// which point into the other sheet (see switchTo).326303 func (m *Model) sheetKey(key string) bool {327- switch keymap[key] {328- case "sheet.next":329- if s := m.sheetAt(1); s != nil {330- m.pointInto(s)331- }332- case "sheet.prev":333- if s := m.sheetAt(-1); s != nil {334- m.pointInto(s)335- }336- default:337- return false338- }339- return true340-}341-342-// tabSpan is a clickable part of the tab strip on the status line.343-type tabSpan struct {344- kind hitKind // hitTab, hitTabAdd, hitTabPrev or hitTabNext345- index int // the sheet, for hitTab346- x, w int347-}348-349-// tabLabel is a sheet's name as its tab shows it.350-func tabLabel(s *sheet.Sheet) string {351- return " " + ansi.Truncate(s.Name(), maxTabName, "…") + " "352-}353-354-// minTabs is the narrowest the tab strip gets: the shown sheet's tab,355-// the arrows and the +.356-func (m *Model) minTabs() int {357- w := ansi.StringWidth(tabLabel(m.sheet)) + 4358- if m.book().Len() > 1 {359- w += 4360- }361- return w362-}363-364-// allTabs is how wide the tab strip is with every tab showing.365-func (m *Model) allTabs() int {366- w := 3 // the +367- for _, s := range m.book().Sheets() {368- w += ansi.StringWidth(tabLabel(s)) + 1369- }370- return w371-}372-373-// tabStrip lays out the tabs in room columns from the left of the status374-// line: as many as fit, always the shown one, the first shown staying put375-// until the shown one would fall off (m.tabs.left), ‹ and › where tabs are376-// hidden, then the +.377-func (m *Model) tabStrip(room int) (string, []tabSpan) {378- sheets := m.book().Sheets()379- active := m.book().Index(m.sheet)380- widths := make([]int, len(sheets))381- for i, s := range sheets {382- widths[i] = ansi.StringWidth(tabLabel(s))383- }384- const add, arrow = 3, 2 // " + ", and "‹" or "›" with the space after it385- fits := func(first int) (last int, ok bool) {386- w := add387- if first > 0 {388- w += arrow389- }390- last = first - 1391- for i := first; i < len(sheets); i++ {392- more := 0393- if i < len(sheets)-1 {394- more = arrow395- }396- if w+widths[i]+1+more > room {397- break398- }399- w += widths[i] + 1400- last = i401- }402- return last, last >= active403- }404- first := clamp(m.tabs.left, 0, active)405- last, ok := fits(first)406- for !ok && first < active {407- first++408- last, ok = fits(first)409- }410- // Show tabs to the left again when there's room, e.g. once the411- // screen is wider.412- for first > 0 {413- l, ok := fits(first - 1)414- if !ok || l < last {415- break416- }417- first, last = first-1, l418- }419- m.tabs.left = first420- if last < active {421- last = active // a name too long for the room is cut below422- }423-424- var b strings.Builder425- var spans []tabSpan426- x := 0427- part := func(kind hitKind, index int, text string) {428- spans = append(spans, tabSpan{kind: kind, index: index, x: x, w: ansi.StringWidth(text)})429- b.WriteString(text + " ")430- x += ansi.StringWidth(text) + 1431- }432- if first > 0 {433- part(hitTabPrev, 0, m.th.Muted.Render("‹"))434- }435- for i := first; i <= last; i++ {436- style := m.th.Tab437- switch {438- case i == active:439- style = m.th.TabActive440- case m.mouse.hover.kind == hitTab && m.mouse.hover.addr.Col == i && (m.mouse.drag == dragNone || m.mouse.drag == dragTab):441- style = m.th.TabHover442- }443- label := tabLabel(sheets[i])444- if i == active && x+ansi.StringWidth(label)+add > room {445- label = ansi.Truncate(label, max(room-x-add, 3), "…")446- }447- part(hitTab, i, style.Render(label))448- }449- if last < len(sheets)-1 {450- part(hitTabNext, 0, m.th.Muted.Render("›"))451- }452- addStyle := m.th.Muted453- if m.mouse.hover.kind == hitTabAdd {454- addStyle = m.th.TabHover455- }456- part(hitTabAdd, 0, addStyle.Render(" + "))457- return strings.TrimSuffix(b.String(), " "), spans458-}459-460-// tabAt returns the part of the tab strip at x on the status line.461-func (m *Model) tabAt(x int) (tabSpan, bool) {462- _, spans := m.statusLayout()463- for _, sp := range spans {464- if x >= sp.x && x < sp.x+sp.w {465- return sp, true466- }467- }468- return tabSpan{}, false469-}470-471-// tabPress handles a click on the tab strip: show the tab (renaming it on472-// a double click, or starting to drag it), add a sheet, or step through473-// tabs with the arrows.474-func (m *Model) tabPress(h hit, double bool) tea.Cmd {475- switch h.kind {476- case hitTabPrev:477- m.stepEntrySheet(-1)478- case hitTabNext:479- m.stepEntrySheet(1)480- case hitTabAdd:481- if m.mode == modePoint {482- m.resumeEntry(m.pointRef())483- }484- if m.editing() && !m.commit() {485- return nil486- }487- return m.runCommand("sheet.new")488- case hitTab:489- s := m.book().Sheet(h.addr.Col)490- if m.editing() {491- m.pointInto(s)492- return nil493- }494- m.showSheet(s)495- if double {496- m.openRename()497- return nil498- }499- m.mouse.drag = dragTab500- }501- return nil502-}503-504-func (m *Model) stepEntrySheet(d int) {505- if s := m.sheetAt(d); s != nil {506- m.switchTo(s)507- }508-}509-510-// dropTab moves the dragged sheet to the tab it was released over.511-func (m *Model) dropTab() {512- if m.mouse.hover.kind == hitTab && m.mouse.hover.addr.Col != m.book().Index(m.sheet) {513- to := m.mouse.hover.addr.Col514- m.book().MoveSheet(m.sheet, to)515- m.note = "Moved " + m.sheet.Name() + " to position " + strconv.Itoa(to+1)516- }517-}518-519-// tabRightClick shows the tab's sheet and opens its menu.520-func (m *Model) tabRightClick(h hit, x, y int) {521- if h.kind != hitTab {522- if h.kind == hitTabAdd {523- m.showContextMenu([]menuItem{{cmd: "sheet.new"}, {cmd: "sheet.goto"}}, x, y)524- }525- return304+ switch id := keymap[key]; id {305+ case "sheet.next", "sheet.prev":306+ m.runCommand(id)307+ return true526308 }527- m.showSheet(m.book().Sheet(h.addr.Col))528- m.showContextMenu(tabMenu, x, y)309+ return false529310 }@@ -0,0 +1,248 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+ "charm.land/lipgloss/v2"9+ "github.com/charmbracelet/x/ansi"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/ui/theme"13+)14+15+// The tab strip at the left of the status line, the way tmux lists16+// windows: the sheet shown is highlighted, a + adds a sheet, and when17+// the tabs don't all fit, ‹ and › step through them. Clicking a tab shows18+// it, double-clicking renames it, right-clicking opens its menu, and19+// dragging it moves it.20+21+// tabStrip is the sheet tabs' state: where each sheet was left, and how22+// far the strip is scrolled.23+type tabStrip struct {24+ places map[*sheet.Sheet]place // where each sheet's cursor and scroll were left25+ left int // the first tab shown when they don't all fit26+}27+28+// place is where a sheet's cursor and scroll position were when it was29+// last shown.30+type place struct {31+ cur sheet.Addr32+ top, left int33+}34+35+// maxTabName is how much of a long sheet name a tab shows.36+const maxTabName = 2037+38+// The widths of the strip's fixed parts: " + ", and "‹" or "›" with the39+// space after it.40+const tabAddW, tabArrowW = 3, 241+42+// tabView is what the tab strip shows: the sheets, the one shown, and43+// what the mouse is over or dragging.44+type tabView struct {45+ sheets []*sheet.Sheet46+ active int47+ hover hit48+ drag dragKind49+}50+51+func (m *Model) tabView() tabView {52+ return tabView{sheets: m.book().Sheets(), active: m.book().Index(m.sheet), hover: m.mouse.hover, drag: m.mouse.drag}53+}54+55+// tabSpan is a clickable part of the tab strip on the status line.56+type tabSpan struct {57+ kind hitKind // hitTab, hitTabAdd, hitTabPrev or hitTabNext58+ index int // the sheet, for hitTab59+ x, w int60+}61+62+// tabLabel is a sheet's name as its tab shows it.63+func tabLabel(s *sheet.Sheet) string {64+ return " " + ansi.Truncate(s.Name(), maxTabName, "…") + " "65+}66+67+// minWidth is the narrowest the tab strip gets: the shown sheet's tab,68+// the arrows and the +.69+func (v tabView) minWidth() int {70+ w := ansi.StringWidth(tabLabel(v.sheets[v.active])) + 471+ if len(v.sheets) > 1 {72+ w += 473+ }74+ return w75+}76+77+// fullWidth is how wide the tab strip is with every tab showing.78+func (v tabView) fullWidth() int {79+ w := tabAddW80+ for _, s := range v.sheets {81+ w += ansi.StringWidth(tabLabel(s)) + 182+ }83+ return w84+}85+86+// style is the style of tab i: the one shown, one under the mouse (or87+// where a dragged tab would go), or plain.88+func (v tabView) style(th *theme.Theme, i int) lipgloss.Style {89+ switch {90+ case i == v.active:91+ return th.TabActive92+ case v.hover.kind == hitTab && v.hover.addr.Col == i && (v.drag == dragNone || v.drag == dragTab):93+ return th.TabHover94+ }95+ return th.Tab96+}97+98+// layout lays out the tabs in room columns: as many as fit, always the99+// shown one, the first shown staying put until the shown one would fall100+// off (t.left), ‹ and › where tabs are hidden, then the +.101+func (t *tabStrip) layout(th *theme.Theme, v tabView, room int) (string, []tabSpan) {102+ widths := make([]int, len(v.sheets))103+ for i, s := range v.sheets {104+ widths[i] = ansi.StringWidth(tabLabel(s))105+ }106+ first, last := t.scroll(widths, v.active, room)107+ var w tabWriter108+ if first > 0 {109+ w.part(hitTabPrev, 0, th.Muted.Render("‹"))110+ }111+ for i := first; i <= last; i++ {112+ label := tabLabel(v.sheets[i])113+ if i == v.active && w.x+ansi.StringWidth(label)+tabAddW > room {114+ label = ansi.Truncate(label, max(room-w.x-tabAddW, 3), "…")115+ }116+ w.part(hitTab, i, v.style(th, i).Render(label))117+ }118+ if last < len(v.sheets)-1 {119+ w.part(hitTabNext, 0, th.Muted.Render("›"))120+ }121+ addStyle := th.Muted122+ if v.hover.kind == hitTabAdd {123+ addStyle = th.TabHover124+ }125+ w.part(hitTabAdd, 0, addStyle.Render(" + "))126+ return strings.TrimSuffix(w.b.String(), " "), w.spans127+}128+129+// scroll picks the first and last tab shown, keeping the first where it130+// was unless the active tab would fall off, and showing tabs to the left131+// again when there's room, e.g. once the screen is wider.132+func (t *tabStrip) scroll(widths []int, active, room int) (first, last int) {133+ first = clamp(t.left, 0, active)134+ last, ok := tabsFit(widths, first, active, room)135+ for !ok && first < active {136+ first++137+ last, ok = tabsFit(widths, first, active, room)138+ }139+ for first > 0 {140+ l, ok := tabsFit(widths, first-1, active, room)141+ if !ok || l < last {142+ break143+ }144+ first, last = first-1, l145+ }146+ t.left = first147+ return first, max(last, active) // a name too long for the room is cut148+}149+150+// tabsFit returns the last tab that fits in room when the strip starts151+// at tab first, and whether the active tab is among those shown.152+func tabsFit(widths []int, first, active, room int) (last int, ok bool) {153+ w := tabAddW154+ if first > 0 {155+ w += tabArrowW156+ }157+ last = first - 1158+ for i := first; i < len(widths); i++ {159+ more := 0160+ if i < len(widths)-1 {161+ more = tabArrowW162+ }163+ if w+widths[i]+1+more > room {164+ break165+ }166+ w += widths[i] + 1167+ last = i168+ }169+ return last, last >= active170+}171+172+// tabWriter writes the parts of the tab strip, noting where each is.173+type tabWriter struct {174+ b strings.Builder175+ spans []tabSpan176+ x int177+}178+179+func (w *tabWriter) part(kind hitKind, index int, text string) {180+ width := ansi.StringWidth(text)181+ w.spans = append(w.spans, tabSpan{kind: kind, index: index, x: w.x, w: width})182+ w.b.WriteString(text + " ")183+ w.x += width + 1184+}185+186+// tabAt returns the part of the tab strip at x on the status line.187+func (m *Model) tabAt(x int) (tabSpan, bool) {188+ _, spans := m.statusLayout()189+ for _, sp := range spans {190+ if x >= sp.x && x < sp.x+sp.w {191+ return sp, true192+ }193+ }194+ return tabSpan{}, false195+}196+197+// tabPress handles a click on the tab strip: show the tab (renaming it on198+// a double click, or starting to drag it), add a sheet, or step through199+// tabs with the arrows.200+func (m *Model) tabPress(h hit, double bool) tea.Cmd {201+ switch h.kind {202+ case hitTabPrev:203+ return m.runCommand("sheet.prev")204+ case hitTabNext:205+ return m.runCommand("sheet.next")206+ case hitTabAdd:207+ if m.mode == modePoint {208+ m.resumeEntry(m.pointRef())209+ }210+ if m.editing() && !m.commit() {211+ return nil212+ }213+ return m.runCommand("sheet.new")214+ case hitTab:215+ s := m.book().Sheet(h.addr.Col)216+ if m.editing() {217+ m.pointInto(s)218+ return nil219+ }220+ m.showSheet(s)221+ if double {222+ return m.runCommand("sheet.rename")223+ }224+ m.mouse.drag = dragTab225+ }226+ return nil227+}228+229+// dropTab moves the dragged sheet to the tab it was released over.230+func (m *Model) dropTab() {231+ if m.mouse.hover.kind == hitTab && m.mouse.hover.addr.Col != m.book().Index(m.sheet) {232+ to := m.mouse.hover.addr.Col233+ m.book().MoveSheet(m.sheet, to)234+ m.note = "Moved " + m.sheet.Name() + " to position " + strconv.Itoa(to+1)235+ }236+}237+238+// tabRightClick shows the tab's sheet and opens its menu.239+func (m *Model) tabRightClick(h hit, x, y int) {240+ if h.kind != hitTab {241+ if h.kind == hitTabAdd {242+ m.showContextMenu([]menuItem{{cmd: "sheet.new"}, {cmd: "sheet.goto"}}, x, y)243+ }244+ return245+ }246+ m.showSheet(m.book().Sheet(h.addr.Col))247+ m.showContextMenu(tabMenu, x, y)248+}