cat / 012
clone

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

Linked sources in the UI: Link a source, a paged tab with a scrollbar, sort and filter pushed down, formulas answered in the background

Dami committed f4a6e16
commit
f4a6e165ef1965e2ac3aeb392185753f63e701d7
parent
1fe5ba08f54df22585fe9c44b973b21124c986aa
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.4 of 3, Jev's estimate
16 files changed, +1670 -8
Ainternal/paged/pages.go190+++++++++++++++++++++++
Minternal/ui/commands.go6+
Minternal/ui/file.go1+
Minternal/ui/linked.go22++-
Minternal/ui/model.go8+-
Minternal/ui/model_test.go3+
Minternal/ui/overlay.go3+
Minternal/ui/panel.go10+
Ainternal/ui/sources.go243+++++++++++++++++++++++++++++
Ainternal/ui/sources_test.go193+++++++++++++++++++++++
Ainternal/ui/srccmds.go227+++++++++++++++++++++++++++
Ainternal/ui/srcscreen.go247++++++++++++++++++++++++++++++
Ainternal/ui/srcview/draw.go223+++++++++++++++++++++++++++
Ainternal/ui/srcview/keys.go42+++++
Ainternal/ui/srcview/view.go250++++++++++++++++++++++++++++++
Minternal/ui/tabstrip/tabstrip.go10+
A internal/paged/pages.go +190 -0
@@ -0,0 +1,190 @@
1+package paged2+3+import (4+ "context"5+ "slices"6+7+ "github.com/FelineStateMachine/012/internal/fileio"8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// Pages are the rows a source's tab shows, in its view's order (a sort12+// and a filter, pushed down to SQL or streamed: fileio.SourceView), read13+// a page at a time as the tab scrolls, and at most keepPages of them14+// kept, so memory stays bounded whatever the source's size. Building a15+// sorted or filtered view reads the whole source once; until it's built,16+// and while a page is on its way, the rows it would show aren't known.17+type Pages struct {18+ h fileio.Source19+ gen int20+ order sheet.SourceOrder21+22+ view fileio.SourceView23+ building bool24+ err string25+26+ pages map[int64]*page // by page number27+ used int64 // counts page reads, for dropping the least used28+ want map[int64]bool // pages asked for or on their way29+ fresh []*PageJob30+}31+32+// page is PageRows rows of the view: each one's number in the source33+// and its values.34+type page struct {35+ nums []int6436+ rows [][]sheet.LiveCell37+ used int6438+}39+40+// PageRows is how many rows a page holds; keepPages is how many are41+// kept.42+const (43+ PageRows = 12844+ keepPages = 4845+)46+47+// NewPages are the pages of the view of h, the gen'th opening of its48+// source, ordered as o says.49+func NewPages(h fileio.Source, gen int, o *sheet.SourceOrder) *Pages {50+ p := &Pages{h: h, gen: gen, pages: map[int64]*page{}, want: map[int64]bool{}}51+ if o != nil {52+ p.order = *o53+ }54+ p.building = true55+ p.fresh = append(p.fresh, &PageJob{p: p, build: true})56+ return p57+}58+59+// Same reports whether the pages are of h's gen'th opening, ordered as o60+// says.61+func (p *Pages) Same(h fileio.Source, gen int, o *sheet.SourceOrder) bool {62+ var ord sheet.SourceOrder63+ if o != nil {64+ ord = *o65+ }66+ return p.h == h && p.gen == gen && sameOrder(p.order, ord)67+}68+69+func sameOrder(a, b sheet.SourceOrder) bool {70+ return slices.Equal(a.Sort, b.Sort) && slices.Equal(a.Filter, b.Filter)71+}72+73+// Close lets go of the view.74+func (p *Pages) Close() {75+ if p.view != nil {76+ p.view.Close()77+ }78+}79+80+// Rows counts the rows the view shows, with false while it's built.81+func (p *Pages) Rows() (int64, bool) {82+ if p.view == nil {83+ return 0, false84+ }85+ return p.view.Rows(), true86+}87+88+// Building reports whether the view is being built; Err says why it89+// couldn't be.90+func (p *Pages) Building() bool { return p.building }91+func (p *Pages) Err() string { return p.err }92+93+// Row is the view's row i: its number in the source and its values,94+// with false while its page isn't read, which Want asks for.95+func (p *Pages) Row(i int64) (int64, []sheet.LiveCell, bool) {96+ pg := p.pages[i/PageRows]97+ if pg == nil || int(i%PageRows) >= len(pg.rows) {98+ return 0, nil, false99+ }100+ p.used++101+ pg.used = p.used102+ return pg.nums[i%PageRows], pg.rows[i%PageRows], true103+}104+105+// Want asks for the pages holding rows from through to of the view, to106+// be read unless they are.107+func (p *Pages) Want(from, to int64) {108+ if p.view == nil {109+ return110+ }111+ last := p.view.Rows() - 1112+ for n := max(from, 0) / PageRows; n <= min(to, last)/PageRows; n++ {113+ if p.pages[n] != nil || p.want[n] {114+ continue115+ }116+ p.want[n] = true117+ p.fresh = append(p.fresh, &PageJob{p: p, n: n, view: p.view})118+ }119+}120+121+// Jobs hands out the reads asked for since the last call, to run in the122+// background.123+func (p *Pages) Jobs() []*PageJob {124+ out := p.fresh125+ p.fresh = nil126+ return out127+}128+129+// Store keeps what a job read, back on the owner's goroutine, and130+// reports whether it changed what shows.131+func (p *Pages) Store(j *PageJob) bool {132+ if j.p != p {133+ return false134+ }135+ if j.build {136+ p.building = false137+ p.view, p.err = j.view, ""138+ if j.err != nil {139+ p.err = describe(j.err)140+ }141+ return true142+ }143+ delete(p.want, j.n)144+ if j.err != nil || j.view != p.view {145+ return false146+ }147+ p.pages[j.n] = &page{nums: j.nums, rows: j.rows}148+ p.drop()149+ return true150+}151+152+// drop lets the least used pages go past keepPages.153+func (p *Pages) drop() {154+ for len(p.pages) > keepPages {155+ least, at := int64(-1), int64(0)156+ for n, pg := range p.pages {157+ if least < 0 || pg.used < at {158+ least, at = n, pg.used159+ }160+ }161+ delete(p.pages, least)162+ }163+}164+165+// PageJob builds a view or reads a page of it, on any goroutine.166+type PageJob struct {167+ p *Pages168+ build bool169+ n int64170+ view fileio.SourceView171+ nums []int64172+ rows [][]sheet.LiveCell173+ err error174+}175+176+// Run does the job.177+func (j *PageJob) Run(ctx context.Context) {178+ if j.build {179+ fo := fileio.SourceOrder{}180+ for _, s := range j.p.order.Sort {181+ fo.Sort = append(fo.Sort, fileio.SourceSort{Col: s.Col, Desc: s.Desc})182+ }183+ for _, f := range j.p.order.Filter {184+ fo.Filter = append(fo.Filter, fileio.SourceFilter{Col: f.Col, Cond: f.Cond})185+ }186+ j.view, j.err = j.p.h.View(ctx, fo)187+ return188+ }189+ j.nums, j.rows, j.err = j.view.Page(ctx, j.n*PageRows, PageRows)190+}
M internal/ui/commands.go +6 -0
M internal/ui/file.go +1 -0
M internal/ui/linked.go +15 -7
@@ -76,7 +76,8 @@ func init() {
7676 7777 // linkedItems are Data > Linked file's items.7878 var linkedItems = []menuItem{79- {cmd: "data.link"}, sep, {cmd: "data.link_follow"}, {cmd: "data.link_reload"}, {cmd: "data.link_rows"}, sep, {cmd: "data.unlink"},79+ {cmd: "data.link"}, {cmd: "data.link_source"}, sep, {cmd: "data.link_follow"}, {cmd: "data.link_reload"}, {cmd: "data.link_rows"}, sep, {cmd: "data.unlink"},80+ sep, {cmd: "data.source_all"}, {cmd: "data.source_reload"},8081 }8182 8283 func hasLinked(m *Model) bool { _, ok := m.currentLinked(); return ok }
@@ -140,9 +141,17 @@ func (m *Model) linkFile(name string, place func(*Model, sheet.LinkSource)) tea.
140141 rows(src)141142 return nil142143 }144+ m.chooseTable(name, src, nil, rows)145+ return nil146+}147+148+// chooseTable asks which table of the database name to link, then has149+// done link it: the only one at once, else a picker of them, with more150+// items (a query) after them.151+func (m *Model) chooseTable(name string, src sheet.LinkSource, more []picker.Item, done func(sheet.LinkSource)) {143152 path, ok := m.path("link", name)144153 if !ok {145- return nil154+ return146155 }147156 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)148157 defer cancel()
@@ -150,9 +159,9 @@ func (m *Model) linkFile(name string, place func(*Model, sheet.LinkSource)) tea.
150159 switch {151160 case err != nil:152161 m.fail(fmt.Sprintf("Couldn't read %s: %v", filepath.Base(name), m.root.Scrub(err.Error())))153- case len(tables) == 1:162+ case len(tables) == 1 && len(more) == 0:154163 src.Table = tables[0].Name155- rows(src)164+ done(src)156165 default:157166 var items []picker.Item158167 for _, t := range tables {
@@ -160,13 +169,12 @@ func (m *Model) linkFile(name string, place func(*Model, sheet.LinkSource)) tea.
160169 Pick: func() tea.Cmd {161170 m.closeOverlay()162171 src.Table = t.Name163- rows(src)172+ done(src)164173 return nil165174 }})166175 }167- m.openOverlay(m.newPicker("Link from "+filepath.Base(name), "Type to filter tables", 60, items))176+ m.openOverlay(m.newPicker("Link from "+filepath.Base(name), "Type to filter tables", 60, append(items, more...)))168177 }169- return nil170178 }171179 172180 // askFollowRows asks how many rows a linked file keeps: all of them, up
M internal/ui/model.go +7 -1
@@ -133,6 +133,7 @@ type Model struct {
133133 rec *recorder // a macro being recorded: macrorec.go134134 macros macroState // a macro running, and trust in the file's macros: macrorun.go135135 follow followState // the linked regions followed, and trust in them: follow.go136+ src sourceState // the linked sources read, and their tabs: sources.go, srcscreen.go136137 nb nbState // notebooks' views and the cells running: notebook.go, nbrun.go137138 // out is the output whose grid this model is, nil for the138139 // program's own model: nbgrid.go.
@@ -234,6 +235,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
234235 cmd = m.handleTransfer(msg)235236 case followTickMsg, followMsg:236237 cmd = m.handleFollow(msg)238+ case sourceJobMsg, sourcePageMsg, sourceTickMsg:239+ cmd = m.handleSource(msg)237240 case macroCallMsg:238241 cmd = m.serveMacro(msg)239242 case macroDoneMsg:
@@ -266,7 +269,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
266269 // Any edit may have queued JEV questions.267270 // Chart images follow any change, see graphics.go.268271 // Linked regions may have come, gone or changed: follow.go.269- return m, tea.Batch(cmd, m.jev.send(m.spans.Parent()), m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans), m.syncSixel(msg), m.syncFollowers(), m.notebookSync())272+ return m, tea.Batch(cmd, m.jev.send(m.spans.Parent()), m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans), m.syncSixel(msg), m.syncFollowers(), m.syncSources(), m.notebookSync())270273 }271274 272275 // beginUpdate prepares for an input event and returns the sheet's state
@@ -336,6 +339,9 @@ func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {
336339 if m.sheet.IsNotebook() {337340 return m.notebookReadyKey(k)338341 }342+ if v := m.srcView(); v != nil {343+ return m.sourceReadyKey(v, k)344+ }339345 if m.prefs.vim {340346 if cmd, ok := m.vimKeyPress(k); ok {341347 return cmd
M internal/ui/model_test.go +3 -0
M internal/ui/overlay.go +3 -0
M internal/ui/panel.go +10 -0
A internal/ui/sources.go +243 -0
@@ -0,0 +1,243 @@
1+package ui2+3+import (4+ "context"5+ "strings"6+ "time"7+8+ tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/fileio"11+ "github.com/FelineStateMachine/012/internal/live"12+ "github.com/FelineStateMachine/012/internal/paged"13+ "github.com/FelineStateMachine/012/internal/sheet"14+ "github.com/FelineStateMachine/012/internal/ui/srcview"15+)16+17+// Linked sources (Data > Linked file > Link a source): a Parquet file18+// or a SQLite table or query read in place on a tab of its own. The19+// workbook's paged.Host answers what formulas and pivot tables ask of20+// the sources, its jobs run on goroutines of their own, a few at a21+// time, and their answers come back as messages that recalculate what22+// waited for them. The tab shown reads its rows a page at a time23+// (paged.Pages) as it scrolls. Which sources the host reads is24+// reconciled with the workbook after every update (syncSources), as25+// linked files are (follow.go), and their files are looked at every26+// live.Interval, a source read again once its file has changed.27+28+// sourceState is the linked sources' part of the model.29+type sourceState struct {30+ host *paged.Host31+ links string // the links given to the host, to notice a change32+ failed map[string]string // why a source can't be linked, as told to the workbook33+ pages map[string]*paged.Pages // the rows of each source's tab, by the source's name34+ views map[*sheet.Sheet]*srcview.View // each source tab's view35+ queue []*paged.Job // the host's jobs waiting for a turn36+ running int37+ ticking bool38+ dragBar bool // the scrollbar's thumb is being dragged39+}40+41+// sourceInterval is how often sources' files are looked at; tests lower42+// it.43+var sourceInterval = live.Interval44+45+// sourceParallel is how many of the host's jobs run at once: each may46+// read a whole source.47+const sourceParallel = 348+49+type (50+ sourceJobMsg struct {51+ host *paged.Host52+ j *paged.Job53+ }54+ sourcePageMsg struct {55+ p *paged.Pages56+ j *paged.PageJob57+ }58+ sourceTickMsg struct{ host *paged.Host }59+)60+61+// closeSources lets the workbook's sources go, as a new one is opened.62+func (m *Model) closeSources() {63+ if m.src.host != nil {64+ m.src.host.Close()65+ }66+ for _, p := range m.src.pages {67+ p.Close()68+ }69+}70+71+// syncSources gives the host the workbook's sources, keeps the pages of72+// the source shown, and runs what's queued.73+func (m *Model) syncSources() tea.Cmd {74+ w := m.book()75+ infos := w.Sources()76+ if len(infos) == 0 && m.src.host == nil {77+ return nil78+ }79+ if m.src.host == nil {80+ m.src.host = paged.NewHost(sheet.MaxCells())81+ w.SetSources(m.src.host)82+ }83+ m.linkSources(infos)84+ m.syncPages()85+ if v := m.srcView(); v != nil {86+ v.Prefetch()87+ }88+ cmds := []tea.Cmd{m.runSourceJobs(), m.runPageJobs()}89+ if !m.src.ticking && len(infos) > 0 {90+ m.src.ticking = true91+ h := m.src.host92+ cmds = append(cmds, tea.Tick(sourceInterval, func(time.Time) tea.Msg { return sourceTickMsg{h} }))93+ }94+ return tea.Batch(cmds...)95+}96+97+// linkSources gives the host the sources it may read, their files98+// resolved, and tells the workbook why the others can't be.99+func (m *Model) linkSources(infos []sheet.SourceInfo) {100+ var links []paged.Linked101+ var key strings.Builder102+ for _, info := range infos {103+ src := info.Source104+ path, why := m.sourcePath(src)105+ if why != "" {106+ if m.src.failed[info.Name] != why {107+ if m.src.failed == nil {108+ m.src.failed = map[string]string{}109+ }110+ m.src.failed[info.Name] = why111+ m.book().SetSourceShape(info.Name, sheet.SourceShape{}, why)112+ }113+ continue114+ }115+ delete(m.src.failed, info.Name)116+ spec := fileio.SourceSpec{Path: path, Format: src.Format, Table: src.Table, Query: src.Query}117+ links = append(links, paged.Linked{Name: info.Name, Spec: spec})118+ key.WriteString(info.Name + "\x00" + path + "\x00" + src.Format + "\x00" + src.Table + "\x00" + src.Query + "\x01")119+ }120+ if key.String() != m.src.links {121+ m.src.links = key.String()122+ m.src.host.Link(links)123+ }124+}125+126+// sourcePath is the file a source reads, or why it may not be read:127+// outside the served folder, or outside the workbook's from a workbook128+// made on another computer, until the user trusts it (follow.go).129+func (m *Model) sourcePath(src sheet.LinkSource) (string, string) {130+ if outside(src.Path) && !m.macroTrusted() {131+ m.askLinkTrust()132+ return "", "Not read: the spreadsheet was made on another computer"133+ }134+ path, err := m.root.Resolve(m.linkName(src.Path))135+ if err != nil {136+ return "", m.root.Scrub(err.Error())137+ }138+ return path, ""139+}140+141+// runSourceJobs starts the host's jobs, a few at a time.142+func (m *Model) runSourceJobs() tea.Cmd {143+ h := m.src.host144+ m.src.queue = append(m.src.queue, h.Jobs()...)145+ var cmds []tea.Cmd146+ for m.src.running < sourceParallel && len(m.src.queue) > 0 {147+ j := m.src.queue[0]148+ m.src.queue = m.src.queue[1:]149+ m.src.running++150+ parent := m.spans.Parent()151+ cmds = append(cmds, func() tea.Msg {152+ span := parent.Start("source " + j.String())153+ j.Run(context.Background())154+ span.End()155+ return sourceJobMsg{h, j}156+ })157+ }158+ return tea.Batch(cmds...)159+}160+161+// runPageJobs reads the pages the tab shown asked for.162+func (m *Model) runPageJobs() tea.Cmd {163+ var cmds []tea.Cmd164+ for _, p := range m.src.pages {165+ for _, j := range p.Jobs() {166+ cmds = append(cmds, func() tea.Msg {167+ j.Run(context.Background())168+ return sourcePageMsg{p, j}169+ })170+ }171+ }172+ return tea.Batch(cmds...)173+}174+175+// handleSource takes what a job found, a page read, or a tick.176+func (m *Model) handleSource(msg tea.Msg) tea.Cmd {177+ switch msg := msg.(type) {178+ case sourceJobMsg:179+ if msg.host != m.src.host {180+ return nil // the workbook it was for is gone181+ }182+ m.src.running--183+ paged.Apply(m.book(), m.src.host.Store(msg.j))184+ case sourcePageMsg:185+ if m.src.pages[pagesName(m.src.pages, msg.p)] == msg.p {186+ msg.p.Store(msg.j)187+ }188+ case sourceTickMsg:189+ if msg.host != m.src.host {190+ return nil191+ }192+ m.src.ticking = false193+ m.src.host.Poll()194+ }195+ return nil196+}197+198+// pagesName is the name p is kept by, "" when it isn't kept.199+func pagesName(pages map[string]*paged.Pages, p *paged.Pages) string {200+ for name, q := range pages {201+ if q == p {202+ return name203+ }204+ }205+ return ""206+}207+208+// syncPages keeps the pages of the source tab shown in step with its209+// source: made once it's open, made again when it's read again or its210+// order changes; those of other tabs let go.211+func (m *Model) syncPages() {212+ info, ok := m.sheet.Source()213+ for name, p := range m.src.pages {214+ if !ok || !strings.EqualFold(name, info.Name) {215+ p.Close()216+ delete(m.src.pages, name)217+ }218+ }219+ if !ok {220+ return221+ }222+ h, gen, open := m.src.host.Source(info.Name)223+ p := m.src.pages[info.Name]224+ switch {225+ case !open:226+ if p != nil {227+ p.Close()228+ delete(m.src.pages, info.Name)229+ }230+ case p == nil || !p.Same(h, gen, info.Source.Order):231+ if p != nil {232+ p.Close()233+ }234+ if m.src.pages == nil {235+ m.src.pages = map[string]*paged.Pages{}236+ }237+ m.src.pages[info.Name] = paged.NewPages(h, gen, info.Source.Order)238+ if v := m.src.views[m.sheet]; v != nil {239+ v.Reset()240+ v.Refit()241+ }242+ }243+}
A internal/ui/sources_test.go +193 -0
@@ -0,0 +1,193 @@
1+package ui2+3+import (4+ "os"5+ "strings"6+ "testing"7+8+ "github.com/parquet-go/parquet-go"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// srcRow is a row of the Parquet files the tests link.14+type srcRow struct {15+ ID int64 `parquet:"id"`16+ Region string `parquet:"region"`17+ Amount float64 `parquet:"amount"`18+}19+20+var srcRegions = []string{"North", "South", "East", "West"}21+22+// writeSource writes n rows to name: ids from 0, regions in turn, and23+// amounts of id*3 mod 1000.24+func writeSource(t testing.TB, name string, n int) (sum float64) {25+ t.Helper()26+ f, err := os.Create(name)27+ if err != nil {28+ t.Fatal(err)29+ }30+ w := parquet.NewGenericWriter[srcRow](f, parquet.MaxRowsPerRowGroup(500))31+ rows := make([]srcRow, n)32+ for i := range rows {33+ rows[i] = srcRow{ID: int64(i), Region: srcRegions[i%len(srcRegions)], Amount: float64(i * 3 % 1000)}34+ sum += rows[i].Amount35+ }36+ if _, err := w.Write(rows); err != nil {37+ t.Fatal(err)38+ }39+ if err := w.Close(); err != nil {40+ t.Fatal(err)41+ }42+ if err := f.Close(); err != nil {43+ t.Fatal(err)44+ }45+ return sum46+}47+48+// pumpSources runs what the sources have queued until nothing is.49+func pumpSources(t *testing.T, m *Model) {50+ t.Helper()51+ for range 50 {52+ run(m, m.syncSources())53+ if m.src.host == nil || !m.src.host.Pending() && m.src.running == 0 && !m.pagesPending() {54+ return55+ }56+ }57+ t.Fatal("the sources never settled")58+}59+60+// pagesPending reports whether a page is being read.61+func (m *Model) pagesPending() bool {62+ for _, p := range m.src.pages {63+ if p.Building() {64+ return true65+ }66+ }67+ return false68+}69+70+// linkSales writes sales.parquet with n rows here and links it.71+func linkSales(t *testing.T, m *Model, n int) float64 {72+ t.Helper()73+ sum := writeSource(t, "sales.parquet", n)74+ m.runCommand("data.link_source")75+ press(t, m, "sales.parquet", "<enter>")76+ pumpSources(t, m)77+ m.note = ""78+ return sum79+}80+81+func TestSourceTab(t *testing.T) {82+ t.Chdir(t.TempDir())83+ m := newModel()84+ linkSales(t, m, 3000)85+ if !m.sheet.IsSource() || m.sheet.Name() != "sales" {86+ t.Fatalf("the tab shown is %s", m.sheet.Name())87+ }88+ if l := line(m, contextLine); !strings.Contains(l, "▦ sales.parquet 3,000 rows, 3 columns") {89+ t.Errorf("context line %q", l)90+ }91+ if l := line(m, menuLine); !strings.Contains(l, "SOURCE") {92+ t.Errorf("menu bar %q", l)93+ }94+ if l := line(m, headerLine+1); !strings.Contains(l, "id") || !strings.Contains(l, "region") {95+ t.Errorf("header row %q", l)96+ }97+ if l := line(m, headerLine+3); !strings.HasPrefix(strings.TrimSpace(l), "2 ") || !strings.Contains(l, "North") {98+ t.Errorf("first row %q", l)99+ }100+ if st := status(m); !strings.Contains(st, "▦ sales") {101+ t.Errorf("status %q", st)102+ }103+ // The last row, numbered as the sheet's row it is.104+ press(t, m, "<ctrl+down>")105+ pumpSources(t, m)106+ if l := screen(m); !strings.Contains(l, "3001 ") {107+ t.Errorf("the last row isn't shown:\n%s", l)108+ }109+ if name := strings.Fields(line(m, formulaLine))[0]; name != "A3001" {110+ t.Errorf("name box %q", name)111+ }112+ if bar(m) != "2999" {113+ t.Errorf("formula bar %q", bar(m))114+ }115+ // Typing doesn't edit.116+ press(t, m, "7", "<enter>")117+ if !m.sheet.IsSource() {118+ t.Error("typing left the tab")119+ }120+}121+122+func TestSourceSortAndFilter(t *testing.T) {123+ t.Chdir(t.TempDir())124+ m := newModel()125+ linkSales(t, m, 2000)126+ press(t, m, "<right>", "<right>") // amount127+ m.runCommand("data.sort_sheet_za")128+ pumpSources(t, m)129+ if l := line(m, contextLine); !strings.Contains(l, "sorted by amount Z to A") {130+ t.Errorf("context line %q", l)131+ }132+ if bar(m) != "999" {133+ t.Errorf("the top amount sorted Z to A is %q", bar(m))134+ }135+ info, _ := m.sheet.Source()136+ o := copyOrder(info.Source.Order)137+ o.Filter = []sheet.SourceFilter{{Col: 1, Cond: sheet.Condition{Op: sheet.CondExactly, Arg: "east"}}}138+ m.setSourceOrder(info, o)139+ pumpSources(t, m)140+ if l := line(m, contextLine); !strings.Contains(l, "showing 500") {141+ t.Errorf("context line %q", l)142+ }143+ press(t, m, "<left>")144+ if bar(m) != "East" {145+ t.Errorf("the filter lets %q through", bar(m))146+ }147+ m.runCommand("data.source_all")148+ pumpSources(t, m)149+ if bar(m) != "North" {150+ t.Errorf("in the source's order the first region is %q", bar(m))151+ }152+ // Undo puts the order back.153+ press(t, m, "<ctrl+z>")154+ pumpSources(t, m)155+ if bar(m) != "East" {156+ t.Errorf("after undo, %q", bar(m))157+ }158+}159+160+func TestFormulasReadASourceTab(t *testing.T) {161+ t.Chdir(t.TempDir())162+ m := newModel()163+ sum := linkSales(t, m, 5000)164+ m.showSheet(m.book().Sheet(0))165+ press(t, m, "=SUM(sales[amount])", "<enter>", `=COUNTIF(sales[region],"west")`, "<enter>", "=sales!C4", "<enter>")166+ pumpSources(t, m)167+ if v := m.sheet.Value(addr("A1")); v.Num != sum {168+ t.Errorf("SUM %v, want %v", v, sum)169+ }170+ if v := m.sheet.Value(addr("A2")); v.Num != 1250 {171+ t.Errorf("COUNTIF %v", v)172+ }173+ if v := m.sheet.Value(addr("A3")); v.Num != 6 {174+ t.Errorf("sales!C4 %v", v)175+ }176+}177+178+func TestSourceCommandsOnTheTab(t *testing.T) {179+ t.Chdir(t.TempDir())180+ m := newModel()181+ linkSales(t, m, 100)182+ if commands["format.bold"].available(m) {183+ t.Error("Bold is offered on a source's tab")184+ }185+ m.runCommand("data.frequency")186+ pumpSources(t, m)187+ if !strings.HasPrefix(m.sheet.Name(), "Frequency of") {188+ t.Fatalf("frequency table on %s", m.sheet.Name())189+ }190+ if v := m.sheet.Value(addr("B2")); v.Num != 1 {191+ t.Errorf("each id is there once: %v", v)192+ }193+}
A internal/ui/srccmds.go +227 -0
@@ -0,0 +1,227 @@
1+package ui2+3+import (4+ "fmt"5+ "path/filepath"6+ "slices"7+8+ tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/fileio"11+ "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/ui/picker"13+)14+15+// Commands on linked sources: Data > Linked file > Link a source makes16+// one, and on its tab the commands that read a sheet's data act on the17+// source instead: sorting and filtering order the rows the tab shows18+// (pushed down to SQLite, streamed over Parquet), a pivot table or a19+// frequency table summarizes the whole source, and Copy copies the20+// active cell. Everything that would change cells says the tab is21+// read-only.22+23+func init() {24+ register(&command{id: "data.link_source", macro: macroNever, title: "Link a source",25+ desc: "Read a Parquet file or a SQLite table or query in place, on a tab of its own, however big: formulas and pivot tables stream over it",26+ run: (*Model).openSourcePicker})27+ register(&command{id: "data.source_all", title: "Original order",28+ desc: "Show the linked source's rows in its own order, unsorted and unfiltered",29+ enabled: func(m *Model) bool { info, ok := m.sheet.Source(); return ok && !info.Source.Order.IsZero() },30+ run: func(m *Model) tea.Cmd {31+ info, _ := m.sheet.Source()32+ return m.setSourceOrder(info, nil)33+ }})34+ register(&command{id: "data.source_reload", macro: macroNever, title: "Read the source again",35+ desc: "Read the linked source's file again, forgetting what formulas and pivot tables found in it",36+ enabled: func(m *Model) bool { return m.sheet.IsSource() },37+ run: func(m *Model) tea.Cmd {38+ info, _ := m.sheet.Source()39+ m.src.links = "" // linked again, so opened again40+ m.src.host.Link(nil)41+ m.note = "Reading " + filepath.Base(info.Source.Path) + " again"42+ return nil43+ }})44+}45+46+// sourceTakes are the commands that act on a source's tab, the source47+// standing for the tab's cells.48+var sourceTakes = map[string]bool{49+ "edit.copy": true, "data.sort_sheet_az": true, "data.sort_sheet_za": true, "data.sort_range_az": true,50+ "data.sort_range_za": true, "data.filter": true, "data.filter_column": true, "data.filter_remove": true,51+ "data.pivot": true, "data.frequency": true, "data.source_all": true, "data.source_reload": true,52+ "data.link_source": true, "data.link": true,53+}54+55+// sourceCommand runs id on a source's tab, reporting whether it did:56+// the commands that read data act on the source, and the rest, which57+// would change cells, say they can't.58+func (m *Model) sourceCommand(c *command) (tea.Cmd, bool) {59+ info, ok := m.sheet.Source()60+ switch {61+ case !ok || notebookSafe(c.id) || c.id == "data.source_all" || c.id == "data.source_reload" || c.id == "data.link_source" || c.id == "data.link":62+ return nil, false63+ case !sourceTakes[c.id]:64+ m.note = c.title + " works on a sheet's cells: this tab is a linked source, read-only"65+ return nil, true66+ case !info.Known:67+ m.note = "The source isn't open yet"68+ return nil, true69+ }70+ v := m.srcView()71+ _, col := v.Active()72+ switch c.id {73+ case "edit.copy":74+ _, text := m.sourceCell(v)75+ m.note = "Copied " + text76+ return tea.SetClipboard(text), true77+ case "data.sort_sheet_az", "data.sort_range_az", "data.sort_sheet_za", "data.sort_range_za":78+ desc := c.id == "data.sort_sheet_za" || c.id == "data.sort_range_za"79+ o := copyOrder(info.Source.Order)80+ o.Sort = []sheet.SourceSort{{Col: col, Desc: desc}}81+ return m.setSourceOrder(info, o), true82+ case "data.filter", "data.filter_column":83+ m.openSourceFilter(info, col)84+ return nil, true85+ case "data.filter_remove":86+ o := copyOrder(info.Source.Order)87+ o.Filter = nil88+ return m.setSourceOrder(info, o), true89+ case "data.pivot", "data.frequency":90+ return m.sourcePivot(info, col, c.id == "data.frequency"), true91+ }92+ return nil, false93+}94+95+// sourceAvailable reports whether a command runs on a source's tab.96+func sourceAvailable(id string) bool { return notebookSafe(id) || sourceTakes[id] }97+98+// copyOrder is a copy of o to change, never nil.99+func copyOrder(o *sheet.SourceOrder) *sheet.SourceOrder {100+ out := &sheet.SourceOrder{}101+ if o != nil {102+ out.Sort, out.Filter = slices.Clone(o.Sort), slices.Clone(o.Filter)103+ }104+ return out105+}106+107+// setSourceOrder orders the source's tab as o says, as an undo step.108+func (m *Model) setSourceOrder(info sheet.SourceInfo, o *sheet.SourceOrder) tea.Cmd {109+ if err := m.book().SetSourceOrder(info.Name, o); err != nil {110+ m.fail(err.Error())111+ }112+ return nil113+}114+115+// openSourceFilter asks for a condition on column col of the source,116+// replacing the column's own.117+func (m *Model) openSourceFilter(info sheet.SourceInfo, col int) {118+ var cond sheet.Condition119+ o := info.Source.Order120+ if o != nil {121+ for _, f := range o.Filter {122+ if f.Col == col {123+ cond = f.Cond124+ }125+ }126+ }127+ title := "Filter " + sheet.ColName(col) + " " + m.sourceCol(info, col)128+ m.openValuesPicker(title, 0, nil, cond, func(cr sheet.Criteria) {129+ o := copyOrder(info.Source.Order)130+ o.Filter = slices.DeleteFunc(o.Filter, func(f sheet.SourceFilter) bool { return f.Col == col })131+ if cr.Cond.Op != sheet.CondNone {132+ o.Filter = append(o.Filter, sheet.SourceFilter{Col: col, Cond: cr.Cond})133+ }134+ m.setSourceOrder(info, o)135+ })136+}137+138+// sourcePivot makes a pivot table over the whole source on a new sheet,139+// or with frequency a frequency table of column col.140+func (m *Model) sourcePivot(info sheet.SourceInfo, col int, frequency bool) tea.Cmd {141+ src := info.Sheet142+ r := sheet.Rect{To: sheet.Addr{Col: len(info.Shape.Cols) - 1, Row: sheet.MaxRows - 1}}143+ p, name := sheet.NewPivot(src, r), ""144+ if frequency {145+ p = sheet.FrequencyPivot(src, r, col)146+ name = "Frequency of " + m.sourceCol(info, col)147+ }148+ start := m.sheet.StateID()149+ s, err := m.book().CreatePivot(src, r, name, p)150+ if err != nil {151+ m.fail(err.Error())152+ return nil153+ }154+ m.showSheet(s)155+ if !frequency {156+ m.openPivotEditor(start, src)157+ }158+ return nil159+}160+161+// openSourcePicker asks for the Parquet file or SQLite database to link162+// as a source: those here, or a path typed.163+func (m *Model) openSourcePicker() tea.Cmd {164+ var items []picker.Item165+ names, _ := importable(m.root)166+ for _, name := range names {167+ k, _ := fileio.KindOf(name)168+ if k != fileio.Parquet && k != fileio.SQLite {169+ continue170+ }171+ items = append(items, picker.Item{Title: name, Name: len(name), Detail: k.Label(), Desc: "Link " + name + " as a source",172+ Pick: func() tea.Cmd {173+ m.closeOverlay()174+ return m.linkSource(name)175+ }})176+ }177+ p := m.newPicker("Link a source", "Type to filter, or a path", 72, items)178+ p.Action = "link"179+ p.Enter = func(query string) (tea.Cmd, bool) {180+ if query == "" || len(p.Shown()) > 0 && !filepath.IsAbs(query) {181+ return nil, false182+ }183+ m.closeOverlay()184+ return m.linkSource(query), true185+ }186+ m.openOverlay(p)187+ return nil188+}189+190+// linkSource links the file typed as name as a source, asking which191+// table of a database with several.192+func (m *Model) linkSource(name string) tea.Cmd {193+ spec := fileio.SourceSpec{Path: name}194+ if k, err := fileio.SourceKind(spec); err != nil {195+ m.fail(fmt.Sprintf("Can't link %s: %v", filepath.Base(name), err))196+ return nil197+ } else if k == fileio.Parquet {198+ m.addSource(sheet.LinkSource{Path: linkPath(name, m.filename)})199+ return nil200+ }201+ src := sheet.LinkSource{Path: linkPath(name, m.filename)}202+ query := picker.Item{Title: "A query…", Name: len("A query"), Desc: "Link what a SELECT reads from " + filepath.Base(name),203+ Pick: func() tea.Cmd {204+ m.closeOverlay()205+ m.openText("Link the rows of the query:", "SELECT * FROM ", func(m *Model, text string) tea.Cmd {206+ src.Query = text207+ m.addSource(src)208+ return nil209+ })210+ return nil211+ }}212+ m.chooseTable(name, src, []picker.Item{query}, m.addSource)213+ return nil214+}215+216+// addSource adds a tab linking src after the sheet shown, and shows it.217+func (m *Model) addSource(src sheet.LinkSource) {218+ before := len(m.book().Sources())219+ s, err := m.book().AddSource("", src, m.sheet)220+ if err != nil {221+ m.fail(err.Error())222+ return223+ }224+ m.afterSheetsChange(s, m.book().Index(s))225+ m.linkedHere(before)226+ m.note = "Linked " + filepath.Base(src.Path) + " as " + s.Name() + ": formulas read it as " + s.Name() + "[column]"227+}
A internal/ui/srcscreen.go +247 -0
@@ -0,0 +1,247 @@
1+package ui2+3+import (4+ "path/filepath"5+ "strconv"6+ "strings"7+8+ tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/locale"11+ "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/ui/srcview"13+ "github.com/FelineStateMachine/012/internal/ui/theme"14+ "github.com/FelineStateMachine/012/internal/ui/transfer"15+)16+17+// A linked source's tab on the screen: the menu bar with SOURCE as the18+// mode, the formula bar naming the active cell as the source's sheet19+// does and showing its value, the context line saying what the source20+// is and how it's ordered, then the source's rows where a sheet's grid21+// would be (package srcview). Keys move the active cell; commands that22+// read a tab's data (sort, filter, pivot tables, copy) act on the23+// source, and those that would change cells say it's read-only.24+25+// srcView is the view of the source tab shown, or nil on another.26+func (m *Model) srcView() *srcview.View {27+ if !m.sheet.IsSource() {28+ return nil29+ }30+ v := m.src.views[m.sheet]31+ if v == nil {32+ if m.src.views == nil {33+ m.src.views = map[*sheet.Sheet]*srcview.View{}34+ }35+ v = srcview.New(srcHost{m, m.sheet})36+ m.src.views[m.sheet] = v37+ }38+ v.Resize(m.width, m.height-headerLine-1)39+ return v40+}41+42+// srcHost is a source tab's srcview.Host.43+type srcHost struct {44+ m *Model45+ s *sheet.Sheet46+}47+48+func (h srcHost) Theme() *theme.Theme { return &h.m.th }49+func (h srcHost) Locale() *locale.Locale { return h.m.locale() }50+51+func (h srcHost) Columns() []srcview.Column {52+ info, _ := h.s.Source()53+ cols := make([]srcview.Column, len(info.Shape.Cols))54+ for i, name := range info.Shape.Cols {55+ cols[i].Name = name56+ if i < len(info.Shape.Formats) {57+ cols[i].Format = info.Shape.Formats[i]58+ }59+ }60+ return cols61+}62+63+func (h srcHost) Rows() (int64, bool) {64+ if p := h.pages(); p != nil {65+ return p.Rows()66+ }67+ return 0, false68+}69+70+func (h srcHost) Row(i int64) (int64, []sheet.LiveCell, bool) {71+ if p := h.pages(); p != nil {72+ return p.Row(i)73+ }74+ return 0, nil, false75+}76+77+func (h srcHost) Want(from, to int64) {78+ if p := h.pages(); p != nil {79+ p.Want(from, to)80+ }81+}82+83+func (h srcHost) pages() interface {84+ Rows() (int64, bool)85+ Row(int64) (int64, []sheet.LiveCell, bool)86+ Want(int64, int64)87+} {88+ info, ok := h.s.Source()89+ if !ok {90+ return nil91+ }92+ if p := h.m.src.pages[info.Name]; p != nil {93+ return p94+ }95+ return nil96+}97+98+// sourceLines are the screen's lines on a source tab, from the menu bar99+// down to the line above the status line.100+func (m *Model) sourceLines(v *srcview.View) []string {101+ return append([]string{m.menuBarLine(), m.formulaBar(), m.contextLineText()}, v.Draw()...)102+}103+104+// sourceName is the active cell as the source's tab names it, and its105+// value as text: C5 is the source's fourth row, under the header.106+func (m *Model) sourceCell(v *srcview.View) (string, string) {107+ num, cell, ok := v.Cell()108+ _, col := v.Active()109+ if !ok {110+ return sheet.ColName(col) + "…", ""111+ }112+ a := sheet.Addr{Col: col, Row: int(num) + 1}113+ name := a.String()114+ if a.Row >= sheet.MaxRows {115+ name = sheet.ColName(col) + strconv.FormatInt(num+2, 10)116+ }117+ if cell.V.Kind == sheet.Empty {118+ return name, ""119+ }120+ f := cell.F121+ if f.IsZero() {122+ if cols := (srcHost{m, m.sheet}).Columns(); col < len(cols) {123+ f = cols[col].Format124+ }125+ }126+ return name, sheet.FormatTextIn(cell.V, f, m.locale())127+}128+129+// sourceContext is the context line on a source tab: what it is, how130+// many rows it has, and how they're ordered.131+func (m *Model) sourceContext() (string, string) {132+ info, _ := m.sheet.Source()133+ name := filepath.Base(info.Source.Path)134+ if info.Source.Table != "" {135+ name += " " + info.Source.Table136+ }137+ switch {138+ case info.Err != "":139+ return m.th.Warning.Render("! " + name + ": " + info.Err), ""140+ case !info.Known:141+ return m.th.Muted.Render(sourceMark+" Opening ") + m.th.Key.Render(name) + m.th.Muted.Render("…"), ""142+ }143+ left := m.th.Muted.Render(sourceMark+" ") + m.th.Key.Render(name) + m.th.Muted.Render(" "+m.sourceCounts(info))144+ return left, m.th.KeyHints("Ctrl+↓", "last row", "Alt+D", "sort, filter")145+}146+147+// sourceMark is the glyph a source's tab shows beside its name and on148+// the context line.149+const sourceMark = "▦"150+151+// sourceCounts says how many rows the tab shows of the source's, and152+// how they're ordered.153+func (m *Model) sourceCounts(info sheet.SourceInfo) string {154+ total := int64(info.Shape.Rows)155+ out := transfer.Thousands(int(total)) + plural(int(min(total, 2)), " row", " rows") + ", " +156+ transfer.Thousands(len(info.Shape.Cols)) + plural(len(info.Shape.Cols), " column", " columns")157+ o := info.Source.Order158+ if o.IsZero() {159+ return out160+ }161+ p := m.src.pages[info.Name]162+ switch {163+ case p == nil || p.Building():164+ return out + "; sorting and filtering…"165+ case p.Err() != "":166+ return out + "; " + p.Err()167+ }168+ var parts []string169+ for _, s := range o.Sort {170+ dir := "A to Z"171+ if s.Desc {172+ dir = "Z to A"173+ }174+ parts = append(parts, "sorted by "+m.sourceCol(info, s.Col)+" "+dir)175+ }176+ for _, f := range o.Filter {177+ parts = append(parts, m.sourceCol(info, f.Col)+" "+strings.ToLower(f.Cond.Op.Title())+" "+f.Cond.Arg)178+ }179+ if len(o.Filter) > 0 {180+ if n, ok := p.Rows(); ok {181+ out += "; showing " + transfer.Thousands(int(n))182+ }183+ }184+ return out + "; " + strings.Join(parts, ", ")185+}186+187+// sourceCol names column col of a source: its letter and name.188+func (m *Model) sourceCol(info sheet.SourceInfo, col int) string {189+ if col < len(info.Shape.Cols) {190+ return info.Shape.Cols[col]191+ }192+ return sheet.ColName(col)193+}194+195+// sourceReadyKey is a key on a source tab: the view's, or else the196+// UI's (menus, other sheets, commands that read the source).197+func (m *Model) sourceReadyKey(v *srcview.View, k tea.KeyPressMsg) tea.Cmd {198+ if v.Key(k) {199+ return nil200+ }201+ key := k.String()202+ if i := barMenuFor(key); i >= 0 {203+ m.showBarMenu(i)204+ return nil205+ }206+ if id, ok := keymap[canonicalKey(key)]; ok && commands[id].available(m) {207+ return m.runCommand(id)208+ }209+ cmd, _ := m.runShortcut(key)210+ return cmd211+}212+213+// sourceMouse takes a click, a drag of the scrollbar or the wheel over214+// the source's rows.215+func (m *Model) sourceMouse(msg tea.MouseMsg) (tea.Cmd, bool) {216+ v := m.srcView()217+ mouse := msg.Mouse()218+ if v == nil || m.mode != modeReady || m.overlay != nil || mouse.Y < headerLine || mouse.Y >= m.height-1 {219+ return nil, false220+ }221+ hit := v.HitAt(mouse.X, mouse.Y-headerLine)222+ switch msg := msg.(type) {223+ case tea.MouseClickMsg:224+ switch {225+ case msg.Button != tea.MouseLeft:226+ case hit.Bar:227+ m.src.dragBar = true228+ v.ScrollBar(hit.BarAt, hit.BarLines)229+ case hit.Cell:230+ v.MoveTo(hit.Row, hit.Col)231+ }232+ case tea.MouseMotionMsg:233+ if m.src.dragBar {234+ v.DragBar(mouse.Y - headerLine)235+ }236+ case tea.MouseReleaseMsg:237+ m.src.dragBar = false238+ case tea.MouseWheelMsg:239+ switch msg.Button {240+ case tea.MouseWheelUp:241+ v.Scroll(-3)242+ case tea.MouseWheelDown:243+ v.Scroll(3)244+ }245+ }246+ return nil, true247+}
A internal/ui/srcview/draw.go +223 -0
@@ -0,0 +1,223 @@
1+package srcview2+3+import (4+ "strconv"5+ "strings"6+7+ "github.com/charmbracelet/x/ansi"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/ui/theme"11+)12+13+// Draw draws the view's lines: the column letters, the header row of14+// the columns' names, the divider under it, then the rows, the15+// scrollbar at the right edge. It asks the host for the rows shown and16+// a page either side, so scrolling finds them read.17+func (v *View) Draw() []string {18+ v.Prefetch()19+ lines := v.Lines()20+ v.fit()21+ v.follow()22+ out := []string{v.letters(), v.names(), v.divider()}23+ bar := v.scrollbar(lines)24+ for i := range lines {25+ out = append(out, v.rowLine(v.top+int64(i))+bar[i])26+ }27+ return out28+}29+30+// shown are the columns on screen.31+func (v *View) shown() []int {32+ n := v.fits(v.left)33+ out := make([]int, 0, n)34+ for c := v.left; c < v.left+n && c < v.cols(); c++ {35+ out = append(out, c)36+ }37+ return out38+}39+40+// pad fills what's left of the width after used columns.41+func (v *View) pad(used int) string { return strings.Repeat(" ", max(v.width-used, 0)) }42+43+// letters is the column header: the columns' letters, as a sheet's.44+func (v *View) letters() string {45+ th := v.h.Theme()46+ var b strings.Builder47+ used := v.gutter()48+ b.WriteString(th.Header.Render(strings.Repeat(" ", used)))49+ for _, c := range v.shown() {50+ w := v.colWidth(c)51+ style := th.Header52+ if c == v.col {53+ style = th.HeaderActive54+ }55+ b.WriteString(style.Render(theme.Center(sheet.ColName(c), w)))56+ used += w57+ }58+ return b.String() + th.Header.Render(v.pad(used))59+}60+61+// names is the header row, the sheet's row 1: the columns' names, bold62+// and underlined as a table's header.63+func (v *View) names() string {64+ th := v.h.Theme()65+ var b strings.Builder66+ b.WriteString(th.RowHeader.Render(theme.PadLeft("1", v.gutter()-1) + " "))67+ used := v.gutter()68+ cols := v.h.Columns()69+ for _, c := range v.shown() {70+ w := v.colWidth(c)71+ name := ansi.Truncate(cols[c].Name, w-1, "…")72+ b.WriteString(th.TableHeader.Render(theme.PadRight(" "+name, w)))73+ used += w74+ }75+ return b.String() + v.pad(used)76+}77+78+// divider is the line under the frozen header row.79+func (v *View) divider() string {80+ return v.h.Theme().FrozenLine.Render(strings.Repeat("─", v.width))81+}82+83+// rowLine draws row i of the tab: its number, the sheet's row the84+// source's row is (under the header), and its cells.85+func (v *View) rowLine(i int64) string {86+ th := v.h.Theme()87+ used := v.gutter()88+ if i >= v.rows() {89+ return th.RowHeader.Render(strings.Repeat(" ", used)) + v.pad(used+1)90+ }91+ num, row, ok := v.h.Row(i)92+ hdr := th.RowHeader93+ if i == v.cur {94+ hdr = th.HeaderActive95+ }96+ label := "…"97+ if ok {98+ label = strconv.FormatInt(num+2, 10)99+ }100+ var b strings.Builder101+ b.WriteString(hdr.Render(theme.PadLeft(label, used-1) + " "))102+ for _, c := range v.shown() {103+ w := v.colWidth(c)104+ b.WriteString(v.cell(i, c, w, row, ok))105+ used += w106+ }107+ return b.String() + v.pad(used+1)108+}109+110+// cell draws column c of row i, w wide.111+func (v *View) cell(i int64, c, w int, row []sheet.LiveCell, ok bool) string {112+ th := v.h.Theme()113+ base := th.Cell114+ if i == v.cur && c == v.col {115+ base = th.Pointer116+ }117+ switch {118+ case !ok:119+ if c == v.left {120+ return th.Muted.Inherit(base).Render(theme.PadRight(" …", w))121+ }122+ return base.Render(strings.Repeat(" ", w))123+ case c >= len(row):124+ return base.Render(strings.Repeat(" ", w))125+ }126+ cell := row[c]127+ text, align := sheet.DisplayIn(cell.V, v.format(c, cell), w, v.h.Locale())128+ text = ansi.Truncate(text, w-1, "")129+ switch align {130+ case sheet.AlignRight, sheet.AlignFill:131+ text = theme.PadLeft(text, w-1) + " "132+ case sheet.AlignCenter:133+ text = theme.Center(text, w)134+ default:135+ text = " " + theme.PadRight(text, w-1)136+ }137+ if cell.V.Kind == sheet.Error {138+ return th.ErrorCell.Inherit(base).Render(text)139+ }140+ return base.Render(text)141+}142+143+// scrollbar is the scrollbar's column beside each of lines rows: a144+// track, and a thumb as long as the share of the rows shown and as far145+// down as the window is, drawn with other characters so it reads146+// without color.147+func (v *View) scrollbar(lines int) []string {148+ th := v.h.Theme()149+ out := make([]string, lines)150+ start, size := v.thumb(lines)151+ for i := range out {152+ if i >= start && i < start+size {153+ out[i] = th.Key.Render("┃")154+ } else {155+ out[i] = th.Border.Render("│")156+ }157+ }158+ return out159+}160+161+// thumb is where the scrollbar's thumb starts and how long it is, over162+// lines lines.163+func (v *View) thumb(lines int) (start, size int) {164+ n := v.rows()165+ if n <= int64(lines) {166+ return 0, lines167+ }168+ size = max(int(int64(lines)*int64(lines)/n), 1)169+ room := int64(lines - size)170+ last := n - int64(lines)171+ return int(v.top * room / max(last, 1)), size172+}173+174+// Hit is what the screen position x, y of the view is: a cell (its row175+// and column), the scrollbar, or nothing.176+type Hit struct {177+ Row int64178+ Col int179+ Cell bool180+ Bar bool181+ BarLines int // how many lines the scrollbar has, for ScrollBar182+ BarAt int // the line of the scrollbar hit183+}184+185+// HitAt says what x, y (from the view's first line) is.186+func (v *View) HitAt(x, y int) Hit {187+ lines := v.Lines()188+ if y < firstRow || y >= firstRow+lines {189+ return Hit{}190+ }191+ if x == v.width-1 {192+ return Hit{Bar: true, BarLines: lines, BarAt: y - firstRow}193+ }194+ i := v.top + int64(y-firstRow)195+ if i >= v.rows() {196+ return Hit{}197+ }198+ used := v.gutter()199+ for _, c := range v.shown() {200+ if w := v.colWidth(c); x >= used && x < used+w {201+ return Hit{Row: i, Col: c, Cell: true}202+ }203+ used += v.colWidth(c)204+ }205+ return Hit{}206+}207+208+// ScrollBar moves the window to where line at of a scrollbar of lines209+// lines stands for: a click on the track, or the thumb dragged there.210+func (v *View) ScrollBar(at, lines int) {211+ n := v.rows() - int64(v.Lines())212+ if n <= 0 || lines <= 1 {213+ return214+ }215+ v.ScrollTo(int64(at) * n / int64(lines-1))216+}217+218+// DragBar moves the window to where the scrollbar's line at y (from the219+// view's first line) stands for, as the thumb is dragged.220+func (v *View) DragBar(y int) {221+ lines := v.Lines()222+ v.ScrollBar(min(max(y-firstRow, 0), lines-1), lines)223+}
A internal/ui/srcview/keys.go +42 -0
A internal/ui/srcview/view.go +250 -0
@@ -0,0 +1,250 @@
1+// Package srcview is a linked source's tab: a window of the source's2+// rows drawn as a sheet with a frozen header row, the row numbers and a3+// scrollbar over the whole source, however many millions of rows it4+// has. The rows come from the Host a page at a time as the window moves5+// (paged.Pages); rows on their way show as a dim ellipsis. The view6+// owns the active cell and the scroll; the UI owns the rest.7+package srcview8+9+import (10+ "strconv"11+12+ "github.com/FelineStateMachine/012/internal/locale"13+ "github.com/FelineStateMachine/012/internal/sheet"14+ "github.com/FelineStateMachine/012/internal/ui/theme"15+)16+17+// Column is one of the source's columns.18+type Column struct {19+ Name string20+ Format sheet.Format21+}22+23+// Host is what the view needs of the UI.24+type Host interface {25+ Theme() *theme.Theme26+ Locale() *locale.Locale27+ // Columns are the source's columns, left to right.28+ Columns() []Column29+ // Rows counts the rows the tab shows, false while they're being30+ // counted (a sort or a filter being applied).31+ Rows() (int64, bool)32+ // Row is the tab's row i: its number in the source, counting from33+ // 0, and its values, false while its page is on its way.34+ Row(i int64) (int64, []sheet.LiveCell, bool)35+ // Want asks for the rows from through to.36+ Want(from, to int64)37+}38+39+// View is a source's tab.40+type View struct {41+ h Host42+ width, height int // from the column letters down to the last row line43+ top, cur int64 // the first row shown, the active row: of the tab's rows44+ col, left int // the active column, the first shown45+ widths []int46+ fitted bool // widths fitted to the first rows read47+}48+49+// New is a view of what h shows.50+func New(h Host) *View { return &View{h: h} }51+52+// The view's lines: the column letters, the header row of names, the53+// divider under it, then the rows.54+const (55+ letterLine = iota56+ namesLine57+ dividerLine58+ firstRow59+)60+61+// minWidth and maxWidth bound a column's fitted width.62+const (63+ minWidth = 664+ maxWidth = 3065+)66+67+// Resize gives the view width columns and height lines.68+func (v *View) Resize(width, height int) {69+ v.width, v.height = width, height70+ v.clamp()71+}72+73+// Lines are how many rows show.74+func (v *View) Lines() int { return max(v.height-firstRow, 1) }75+76+// Active is the active cell: its row among the tab's, its column.77+func (v *View) Active() (int64, int) { return v.cur, v.col }78+79+// Top is the first row shown.80+func (v *View) Top() int64 { return v.top }81+82+// Cell is the active cell's number in the source and value, with false83+// while its row is on its way.84+func (v *View) Cell() (int64, sheet.LiveCell, bool) {85+ num, row, ok := v.h.Row(v.cur)86+ if !ok || v.col >= len(row) {87+ return num, sheet.LiveCell{}, ok88+ }89+ return num, row[v.col], true90+}91+92+// Reset goes back to the first row, as when the rows are sorted or93+// filtered again.94+func (v *View) Reset() {95+ v.top, v.cur = 0, 096+ v.clamp()97+}98+99+// rows counts the rows, 0 while they're being counted.100+func (v *View) rows() int64 {101+ n, _ := v.h.Rows()102+ return n103+}104+105+// cols counts the columns.106+func (v *View) cols() int { return len(v.h.Columns()) }107+108+// Move moves the active cell by rows and cols, keeping it in the109+// window.110+func (v *View) Move(rows int64, cols int) {111+ v.cur += rows112+ v.col += cols113+ v.clamp()114+ v.follow()115+}116+117+// MoveTo makes row and col the active cell, row -1 meaning the last.118+func (v *View) MoveTo(row int64, col int) {119+ if row < 0 {120+ row = v.rows() - 1121+ }122+ if col < 0 {123+ col = v.cols() - 1124+ }125+ v.cur, v.col = row, col126+ v.clamp()127+ v.follow()128+}129+130+// Scroll moves the window d rows, leaving the active cell where it is131+// unless it would leave the window.132+func (v *View) Scroll(d int64) {133+ v.top += d134+ v.clamp()135+ if v.cur < v.top {136+ v.cur = v.top137+ }138+ if last := v.top + int64(v.Lines()) - 1; v.cur > last {139+ v.cur = last140+ }141+ v.clamp()142+}143+144+// ScrollTo shows the rows from top on.145+func (v *View) ScrollTo(top int64) { v.Scroll(top - v.top) }146+147+// clamp keeps the active cell and the window in the rows and columns.148+func (v *View) clamp() {149+ n := v.rows()150+ v.cur = max(min(v.cur, n-1), 0)151+ v.top = max(min(v.top, n-int64(v.Lines())), 0)152+ v.col = max(min(v.col, v.cols()-1), 0)153+ v.left = max(min(v.left, v.col), 0)154+}155+156+// follow scrolls the active cell into view.157+func (v *View) follow() {158+ lines := int64(v.Lines())159+ switch {160+ case v.cur < v.top:161+ v.top = v.cur162+ case v.cur >= v.top+lines:163+ v.top = v.cur - lines + 1164+ }165+ for v.col > v.left && v.col >= v.left+v.fits(v.left) {166+ v.left++167+ }168+ v.clamp()169+}170+171+// gutter is the width of the row numbers: the widest a row shows as,172+// counting the header's row 1, and room.173+func (v *View) gutter() int {174+ return max(6, len(strconv.FormatInt(v.rows()+1, 10))+2)175+}176+177+// width is column c's width.178+func (v *View) colWidth(c int) int {179+ if c < len(v.widths) {180+ return v.widths[c]181+ }182+ return sheet.DefaultWidth183+}184+185+// fits is how many columns from left fit beside the row numbers and the186+// scrollbar, at least one.187+func (v *View) fits(left int) int {188+ room := v.width - v.gutter() - 1189+ n := 0190+ for c := left; c < v.cols(); c++ {191+ if room -= v.colWidth(c); room < 0 {192+ break193+ }194+ n++195+ }196+ return max(n, 1)197+}198+199+// fit sizes the columns to their names and the rows first read, once.200+func (v *View) fit() {201+ cols := v.h.Columns()202+ if v.fitted || len(cols) == 0 {203+ return204+ }205+ widths := make([]int, len(cols))206+ for c, col := range cols {207+ widths[c] = max(len(sheet.ColName(c)), len([]rune(col.Name)))208+ }209+ fitted := true210+ for i := v.top; i < v.top+int64(v.Lines()) && i < v.rows(); i++ {211+ _, row, ok := v.h.Row(i)212+ if !ok {213+ fitted = false // fitted again once the window's rows are here214+ break215+ }216+ for c, cell := range row {217+ if c < len(widths) && cell.V.Kind != sheet.Empty {218+ text := sheet.FormatTextIn(cell.V, v.format(c, cell), v.h.Locale())219+ widths[c] = max(widths[c], len([]rune(text)))220+ }221+ }222+ }223+ for c := range widths {224+ widths[c] = min(max(widths[c]+2, minWidth), maxWidth)225+ }226+ v.widths, v.fitted = widths, fitted227+}228+229+// Prefetch asks for the rows shown and a window's worth either side, so230+// scrolling finds them read.231+func (v *View) Prefetch() {232+ v.clamp()233+ lines := int64(v.Lines())234+ v.h.Want(v.top-lines, v.top+2*lines)235+}236+237+// format is what a cell of column c shows in: its own, or its column's.238+func (v *View) format(c int, cell sheet.LiveCell) sheet.Format {239+ if !cell.F.IsZero() {240+ return cell.F241+ }242+ if cols := v.h.Columns(); c < len(cols) {243+ return cols[c].Format244+ }245+ return sheet.Format{}246+}247+248+// Refit sizes the columns again once rows are read, as when the source249+// is read again.250+func (v *View) Refit() { v.fitted = false }
M internal/ui/tabstrip/tabstrip.go +10 -0