Probing the terminal, handling its replies, syncing chart images, notifications and releasing the terminal become methods of terminal; sending JEV questions and describing their progress become methods of jevRunner (nil when JEV is off). Update routes to them, and a JEV answer is handled in one answerJEV step.
- commit
- cd1b078290d5f134d5223fcf288ba73f8f271312
- parent
- 2260941271530e8306e69627d5239a0c4f35a85d
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 2 | +- |
| M | internal/ | 6 | +- |
| M | internal/ | 83 | ++++++++++++++++-------------- |
| M | internal/ | 47 | ++++++++++------- |
| M | internal/ | 6 | +- |
| M | internal/ | 14 | ++--- |
| M | internal/ | 2 | +- |
| M | internal/ | 6 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
@@ -220,7 +220,7 @@ func (m *Model) chartBoxes() []box {
220220 // border with a resize handle in the corner.221221 func (m *Model) drawChart(i int, c sheet.Chart, selected bool) []string {222222 w, h := chartInner(c)223- o := m.chartOptions()223+ o := m.term.chartOptions()224224 if i >= maxImages {225225 o.Image = false226226 }@@ -190,7 +190,7 @@ func TestChartImages(t *testing.T) {
190190 // No kitty reply: text, and nothing sent.191191 m.runCommand("insert.chart")192192 press(t, m, "<enter>", "<esc>")193- if cmd := m.syncImages(); cmd != nil {193+ if cmd := m.term.syncImages(m.sheet, m.displayCharts, &m.th); cmd != nil {194194 t.Error("images sent without kitty graphics")195195 }196196 // The terminal answers the query: images, and placeholders in the view.@@ -213,14 +213,14 @@ func TestChartImages(t *testing.T) {
213213 _, cmd = m.Update(nil)214214 raw = nil215215 m.term.sent[firstImageID] = "stale"216- collect(m.syncImages())216+ collect(m.term.syncImages(m.sheet, m.displayCharts, &m.th))217217 if len(raw) != 1 {218218 t.Errorf("changed chart not sent")219219 }220220 // Deleting the chart frees its image.221221 raw = nil222222 m.sheet.DeleteChart(0)223- collect(m.syncImages())223+ collect(m.term.syncImages(m.sheet, m.displayCharts, &m.th))224224 if len(raw) != 1 || !strings.Contains(raw[0], "a=d,d=I,i=16") {225225 t.Errorf("image not freed: %q", raw)226226 }@@ -13,7 +13,9 @@ import (
1313 "github.com/charmbracelet/x/ansi"1414 1515 "github.com/FelineStateMachine/012/internal/chart"16+ "github.com/FelineStateMachine/012/internal/sheet"1617 "github.com/FelineStateMachine/012/internal/telemetry"18+ "github.com/FelineStateMachine/012/internal/ui/theme"1719 )1820 1921 // terminal is what 012 knows about the terminal it runs in, learned from@@ -46,8 +48,8 @@ func (t *terminal) wrap(seq string) string {
4648 4749 // probes are the startup queries: kitty graphics support, and live4850 // light and dark changes (mode 2031).49-func (m *Model) probes() tea.Cmd {50- return tea.Raw(m.term.wrap(chart.Query()) + ansi.SetModeLightDark)51+func (t *terminal) probes() tea.Cmd {52+ return tea.Raw(t.wrap(chart.Query()) + ansi.SetModeLightDark)5153 }5254 5355 // firstImageID is the image id of the first chart. Placeholders name@@ -58,12 +60,12 @@ const firstImageID = 16
5860 // maxImages caps how many charts become images; the rest stay text.5961 const maxImages = 256 - firstImageID6062 61-// handleTerminal handles replies to queries and terminal events.62-func (m *Model) handleTerminal(msg tea.Msg) tea.Cmd {63+// handle handles replies to queries and terminal events.64+func (t *terminal) handle(msg tea.Msg) tea.Cmd {6365 switch msg := msg.(type) {6466 case uv.KittyGraphicsEvent:65- if msg.Options.ID == chart.QueryID && string(msg.Payload) == "OK" && !m.term.kitty {66- m.term.kitty = true67+ if msg.Options.ID == chart.QueryID && string(msg.Payload) == "OK" && !t.kitty {68+ t.kitty = true6769 // Ask for the cell size (for image proportions) and the6870 // palette colors charts draw with.6971 var q strings.Builder@@ -75,20 +77,20 @@ func (m *Model) handleTerminal(msg tea.Msg) tea.Cmd {
7577 }7678 case uv.CellSizeEvent:7779 if msg.Width > 0 && msg.Height > 0 {78- m.term.cellW, m.term.cellH = msg.Width, msg.Height80+ t.cellW, t.cellH = msg.Width, msg.Height7981 }8082 case uv.UnknownOscEvent:8183 if i, c, ok := parsePaletteReply(string(msg)); ok {82- m.term.palette[i] = c84+ t.palette[i] = c8385 }8486 case uv.DarkColorSchemeEvent, uv.LightColorSchemeEvent:8587 // The system switched between light and dark. The terminal's8688 // background decides the theme, so ask for it again.8789 return tea.RequestBackgroundColor8890 case tea.FocusMsg:89- m.term.blurred = false91+ t.blurred = false9092 case tea.BlurMsg:91- m.term.blurred = true93+ t.blurred = true9294 }9395 return nil9496 }@@ -127,62 +129,63 @@ func (t *terminal) color(i int) color.RGBA {
127129 }128130 129131 // chartPalette is the theme's series colors as the terminal draws them.130-func (m *Model) chartPalette() chart.Palette {132+func (t *terminal) chartPalette(th *theme.Theme) chart.Palette {131133 var p chart.Palette132- for i, idx := range m.th.SeriesANSI {133- p.Series[i] = m.term.color(idx)134+ for i, idx := range th.SeriesANSI {135+ p.Series[i] = t.color(idx)134136 }135- p.Grid = m.term.color(8) // bright black, like the text axes137+ p.Grid = t.color(8) // bright black, like the text axes136138 p.Grid.A = 90137139 return p138140 }139141 140142 // chartOptions are the drawing options for charts on this terminal.141-func (m *Model) chartOptions() chart.Options {142- return chart.Options{Image: m.term.kitty, CellW: m.term.cellW, CellH: m.term.cellH}143+func (t *terminal) chartOptions() chart.Options {144+ return chart.Options{Image: t.kitty, CellW: t.cellW, CellH: t.cellH}143145 }144146 145147 // syncImages sends the images of charts that changed since they were last146-// sent, and frees those of deleted charts. Update calls it after every147-// message, so images follow edits, recalculation, resizing and the theme.148-func (m *Model) syncImages() tea.Cmd {149- if !m.term.kitty {148+// sent, and frees those of deleted charts; shown gives the charts as149+// drawn, asked for only on terminals that show images. Update calls it150+// after every message, so images follow edits, recalculation, resizing151+// and the theme.152+func (t *terminal) syncImages(s *sheet.Sheet, shown func() []sheet.Chart, th *theme.Theme) tea.Cmd {153+ if !t.kitty {150154 return nil151155 }152156 var out strings.Builder153- charts := m.displayCharts()154- o := m.chartOptions()155- pal := m.chartPalette()157+ o := t.chartOptions()158+ pal := t.chartPalette(th)156159 live := map[int]bool{}157- for i, c := range charts {160+ for i, c := range shown() {158161 if i >= maxImages {159162 break160163 }161164 id := firstImageID + i162165 live[id] = true163166 w, h := chartInner(c)164- d := m.sheet.ChartData(c)167+ d := s.ChartData(c)165168 key := fmt.Sprintf("%v %v %d %d %v %v", c.Type, d, w, h, o, pal)166- if m.term.sent[id] == key {169+ if t.sent[id] == key {167170 continue168171 }169- m.term.sent[id] = key172+ t.sent[id] = key170173 span := telemetry.Start("chart.image", slog.String("type", c.Type.String()), slog.Int("w", w), slog.Int("h", h))171174 g := chart.Draw(c.Type, d, w, h, o)172175 img := chart.Image(c.Type, d, w, h, o, pal)173176 if img == nil || g.Plot.Empty() {174177 span.End()175- out.WriteString(chart.Delete(id, m.term.wrap))178+ out.WriteString(chart.Delete(id, t.wrap))176179 continue177180 }178- sent := chart.Transmit(id, img, g.Plot.Dx(), g.Plot.Dy(), m.term.wrap)181+ sent := chart.Transmit(id, img, g.Plot.Dx(), g.Plot.Dy(), t.wrap)179182 span.End(slog.Int("bytes", len(sent)))180183 out.WriteString(sent)181184 }182- for id := range m.term.sent {185+ for id := range t.sent {183186 if !live[id] {184- delete(m.term.sent, id)185- out.WriteString(chart.Delete(id, m.term.wrap))187+ delete(t.sent, id)188+ out.WriteString(chart.Delete(id, t.wrap))186189 }187190 }188191 if out.Len() == 0 {@@ -191,21 +194,21 @@ func (m *Model) syncImages() tea.Cmd {
191194 return tea.Raw(out.String())192195 }193196 194-// releaseTerminal undoes the startup modes and frees the chart images.195-func (m *Model) releaseTerminal() string {197+// release undoes the startup modes and frees the chart images.198+func (t *terminal) release() string {196199 var b strings.Builder197200 b.WriteString(ansi.ResetModeLightDark)198- for id := range m.term.sent {199- b.WriteString(chart.Delete(id, m.term.wrap))201+ for id := range t.sent {202+ b.WriteString(chart.Delete(id, t.wrap))200203 }201204 return b.String()202205 }203206 204-// notifyDone tells the user a long job finished, with a desktop207+// notify tells the user a long job finished, with a desktop205208 // notification (OSC 9) when the terminal window isn't focused. When it is,206209 // the screen already shows the result.207-func (m *Model) notifyDone(msg string) tea.Cmd {208- if !m.term.blurred {210+func (t *terminal) notify(msg string) tea.Cmd {211+ if !t.blurred {209212 return nil210213 }211214 msg = strings.Map(func(r rune) rune {@@ -214,5 +217,5 @@ func (m *Model) notifyDone(msg string) tea.Cmd {
214217 }215218 return r216219 }, msg)217- return tea.Raw(m.term.wrap(ansi.Notify("012: " + msg)))220+ return tea.Raw(t.wrap(ansi.Notify("012: " + msg)))218221 }@@ -11,6 +11,7 @@ import (
1111 "github.com/FelineStateMachine/012/internal/jev"1212 "github.com/FelineStateMachine/012/internal/sheet"1313 "github.com/FelineStateMachine/012/internal/telemetry"14+ "github.com/FelineStateMachine/012/internal/ui/theme"1415 )1516 1617 // JEV functions are answered in the background: the engine queues@@ -68,19 +69,19 @@ func plural(n int, one, many string) string {
6869 return many6970 }7071 71-// sendJEV starts queued questions, keeping at most jevParallel in flight72+// send starts queued questions, keeping at most jevParallel in flight7273 // so a filled-down column doesn't open hundreds of connections at once.73-func (m *Model) sendJEV() tea.Cmd {74- if m.jev == nil {74+func (j *jevRunner) send() tea.Cmd {75+ if j == nil {7576 return nil7677 }77- inFlight, queued := m.jev.cache.Busy()78+ inFlight, queued := j.cache.Busy()7879 telemetry.Set("jev_in_flight", int64(inFlight))7980 telemetry.Set("jev_queued", int64(queued))80- calls := m.jev.cache.Take(jevParallel - inFlight)81+ calls := j.cache.Take(jevParallel - inFlight)8182 cmds := make([]tea.Cmd, len(calls))8283 for i, call := range calls {83- client := m.jev.client84+ client := j.client8485 cmds[i] = func() tea.Msg {8586 ctx, cancel := context.WithTimeout(context.Background(), jevTimeout)8687 defer cancel()@@ -97,21 +98,27 @@ func (m *Model) sendJEV() tea.Cmd {
9798 return tea.Batch(cmds...)9899 }99100 100-// handleJEVAnswer stores an answer and recalculates the JEV cells.101-func (m *Model) handleJEVAnswer(msg jevAnswerMsg) {101+// answerJEV stores an answer and recalculates the JEV cells, telling102+// the user when the last question is answered.103+func (m *Model) answerJEV(msg jevAnswerMsg) tea.Cmd {102104 if m.jev == nil {103- return105+ return nil104106 }107+ busy := m.jev.busy() != ""105108 m.jev.cache.Store(msg.call, msg.answer)106109 m.sheet.RecalcVolatile()110+ if busy && m.jev.busy() == "" {111+ return m.term.notify("JEV finished answering in " + m.displayName())112+ }113+ return nil107114 }108115 109-// jevBusy describes questions still being answered, for the status line.110-func (m *Model) jevBusy() string {111- if m.jev == nil {116+// busy describes questions still being answered, for the status line.117+func (j *jevRunner) busy() string {118+ if j == nil {112119 return ""113120 }114- inFlight, queued := m.jev.cache.Busy()121+ inFlight, queued := j.cache.Busy()115122 switch {116123 case inFlight+queued == 0:117124 return ""@@ -121,18 +128,18 @@ func (m *Model) jevBusy() string {
121128 return fmt.Sprintf("JEV answering %d, %d waiting", inFlight, queued)122129 }123130 124-// jevLine explains the JEV answer in the active cell, for the context line.125-func (m *Model) jevLine() string {126- calls := m.sheet.RemoteCalls(m.cur)131+// line explains the JEV answers of calls, the active cell's, for the132+// context line.133+func (j *jevRunner) line(th *theme.Theme, calls []sheet.RemoteCall) string {127134 if len(calls) == 0 {128135 return ""129136 }130- if m.jev == nil {131- return m.th.Warning.Render("JEV functions need TYPESAFE_API_KEY, in the environment or a .env file")137+ if j == nil {138+ return th.Warning.Render("JEV functions need TYPESAFE_API_KEY, in the environment or a .env file")132139 }133140 var parts []string134141 for _, c := range calls {135- a, ok := m.jev.cache.Answer(c)142+ a, ok := j.cache.Answer(c)136143 if !ok {137144 parts = append(parts, "JEV: asking…")138145 continue@@ -143,5 +150,5 @@ func (m *Model) jevLine() string {
143150 for _, p := range parts[1:] {144151 line += " " + p145152 }146- return m.th.Muted.Render(line)153+ return th.Muted.Render(line)147154 }@@ -102,16 +102,16 @@ func TestLiveLightDark(t *testing.T) {
102102 103103 func TestNotifyOnlyWhenBlurred(t *testing.T) {104104 m := newModel()105- if m.notifyDone("done") != nil {105+ if m.term.notify("done") != nil {106106 t.Error("notified while focused")107107 }108108 send(m, tea.BlurMsg{})109- msgs := flatten(m.notifyDone("Import done\x1b]0;x\x07"))109+ msgs := flatten(m.term.notify("Import done\x1b]0;x\x07"))110110 if len(msgs) != 1 || msgs[0].(tea.RawMsg).Msg != "\x1b]9;012: Import done]0;x\x07" {111111 t.Errorf("notification %q", msgs)112112 }113113 send(m, tea.FocusMsg{})114- if m.notifyDone("done") != nil {114+ if m.term.notify("done") != nil {115115 t.Error("notified after focus came back")116116 }117117 if !m.View().ReportFocus {@@ -92,8 +92,8 @@ func New(s *sheet.Sheet, filename string) *Model {
9292 // Init implements tea.Model. It asks the terminal for its background color9393 // so the theme can adapt to light terminals.9494 func (m *Model) Init() tea.Cmd {95- // sendJEV starts any questions queued while loading the file.96- return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.probes(), m.sendJEV(), m.startupCmd())95+ // jev.send starts any questions queued while loading the file.96+ return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(), m.startupCmd())9797 }9898 9999 // Update implements tea.Model.@@ -145,15 +145,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
145145 m.prompt.files = msg146146 }147147 case jevAnswerMsg:148- busy := m.jevBusy() != ""149- m.handleJEVAnswer(msg)150- if busy && m.jevBusy() == "" {151- cmd = m.notifyDone("JEV finished answering in " + m.displayName())152- }148+ cmd = m.answerJEV(msg)153149 case importedMsg, importTickMsg, exportedMsg:154150 cmd = m.handleTransfer(msg)155151 default:156- cmd = m.handleTerminal(msg)152+ cmd = m.term.handle(msg)157153 }158154 // Scroll only when the focus moves, so the mouse wheel can look around159155 // without the view snapping back, as in Sheets.@@ -164,7 +160,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
164160 m.clampView()165161 // Any edit may have queued JEV questions.166162 // Chart images follow any change, see graphics.go.167- return m, tea.Batch(cmd, m.sendJEV(), m.syncImages())163+ return m, tea.Batch(cmd, m.jev.send(), m.term.syncImages(m.sheet, m.displayCharts, &m.th))168164 }169165 170166 // beginUpdate prepares for an input event and returns the sheet's state@@ -435,5 +435,5 @@ const (
435435 // exit restores the pointer shape, Shift+click handling and the other436436 // terminal modes 012 changed, frees chart images, then quits.437437 func (m *Model) exit() tea.Cmd {438- return tea.Sequence(tea.Raw(ansi.SetPointerShape("default")+shiftEscapeOff+m.releaseTerminal()), tea.Quit)438+ return tea.Sequence(tea.Raw(ansi.SetPointerShape("default")+shiftEscapeOff+m.term.release()), tea.Quit)439439 }@@ -48,7 +48,7 @@ func (m *Model) View() tea.View {
4848 v.MouseMode = tea.MouseModeAllMotion // hover feedback; see mouse.go4949 v.ReportFocus = true // notifications only when the window is in the background5050 v.WindowTitle = "012 - " + m.displayName()51- if m.jevBusy() != "" {51+ if m.jev.busy() != "" {5252 // Terminals that support it (OSC 9;4) show activity in the tab.5353 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, 0)5454 }@@ -155,7 +155,7 @@ func (m *Model) contextLineText() string {
155155 left, right = m.traceLine()156156 case m.mode == modeReady:157157 if left = m.readyLine(); left == "" {158- left = m.jevLine()158+ left = m.jev.line(&m.th, m.sheet.RemoteCalls(m.cur))159159 }160160 if left == "" {161161 left = m.errorLine()@@ -316,7 +316,7 @@ func (m *Model) statusState() string {
316316 if n := m.sheet.HiddenRows(); n > 0 {317317 b.WriteString(" " + m.th.Hint.Render("Filter hides "+rowCount(n)))318318 }319- if busy := m.jevBusy(); busy != "" {319+ if busy := m.jev.busy(); busy != "" {320320 b.WriteString(" " + m.th.Hint.Render(busy))321321 }322322 return b.String()@@ -306,7 +306,7 @@ func (m *Model) handleImported(msg importedMsg) tea.Cmd {
306306 m.note += "; " + strings.Join(msg.res.Notes, "; ")307307 }308308 // A long import may finish while the terminal is in the background.309- return m.notifyDone("Imported " + what)309+ return m.term.notify("Imported " + what)310310 }311311 312312 // openTablePicker asks which table of a SQLite database to import, or