Init asked for the background in a command of its own, racing the startup probes, and drew the dark theme until the answer came back: a frame of the wrong palette for as long as a question takes over SSH. make screens caught it as share-presence-light drawn dark, 2 to 7 in 100 runs, the answer arriving 35 ms after the first frame. The background is asked in the probes, before DA1; the screen stays blank until its answer, DA1's (a terminal that doesn't say its background) or 500 ms. The ui tests' clock (after) ends that wait at once, and serve's test terminals answer DA1 as a terminal would.
- commit
- 669bab5ae3ce3bccb5b6be0b3efef8670b1a3260
- parent
- 61b082d30b9ea3e8d71ca6e991f5e51f08ccb096
- author
- Dami <dami.etoile@gmail.com>
- kind
- fix risk score 1.9 of 3, Jev's estimate
| M | internal/ | 7 | ++++ |
| M | internal/ | 19 | +++++++--- |
| M | internal/ | 7 | ++-- |
| M | internal/ | 8 | +---- |
| M | internal/ | 4 | +- |
| M | internal/ | 15 | ++++++--- |
| M | internal/ | 2 | +- |
| M | internal/ | 30 | +++++++++++++++++ |
| A | internal/ | 53 | ++++++++++++++++++++++++++++++ |
@@ -18,6 +18,7 @@ import (
1818 "time"1919 2020 typesafe "github.com/FelineStateMachine/typesafe-go"21+ "github.com/charmbracelet/x/ansi"2122 gossh "golang.org/x/crypto/ssh"2223 2324 "github.com/FelineStateMachine/012/internal/sheet"@@ -87,6 +88,9 @@ func dial(addr string, key gossh.Signer, host gossh.PublicKey) (*gossh.Client, e
8788 }8889 8990 // term is a client session with a terminal, collecting what it's sent.91+// term is a client's terminal: it keeps what the session writes, and92+// answers the device attributes as a terminal would, which 012 waits for93+// before it draws (ui/termbg.go).9094 type term struct {9195 sess *gossh.Session9296 stdin io.WriteCloser@@ -116,6 +120,9 @@ func openTerm(t testing.TB, c *gossh.Client, w, h int) *term {
116120 }117121 118122 func (tm *term) Write(p []byte) (int, error) {123+ if bytes.Contains(p, []byte(ansi.RequestPrimaryDeviceAttributes)) {124+ go io.WriteString(tm.stdin, "\x1b[?62;22c")125+ }119126 tm.mu.Lock()120127 n, err := tm.out.Write(p)121128 tm.mu.Unlock()@@ -41,6 +41,7 @@ type terminal struct {
4141 sent map[int]string // image id -> what was sent, to send only changes4242 six sixelState // the sixel images drawn and to draw4343 blurred bool // the terminal window doesn't have focus44+ waitBG bool // the screen waits for the background (termbg.go)4445 }4546 4647 // newTerminal starts with what getenv, the terminal's environment, says:@@ -58,13 +59,16 @@ func (t *terminal) wrap(seq string) string {
5859 }5960 6061 // probes are the startup queries: kitty graphics support, live light61-// and dark changes (mode 2031), the 16 palette colors (OSC 4), which62-// chart images and color scales draw with, the sixel color registers63-// (XTSMGRAPHICS) and, last, the primary device attributes. Terminals64-// answer in order, so the kitty and XTSMGRAPHICS replies, if any, come65-// before DA1's, which then decides between kitty images, sixel and text.62+// and dark changes (mode 2031), the background color (OSC 11), which63+// picks the theme, the 16 palette colors (OSC 4), which chart images64+// and color scales draw with, the sixel color registers (XTSMGRAPHICS)65+// and, last, the primary device attributes. Terminals answer in order,66+// so the other replies, if any, come before DA1's, which then decides67+// between kitty images, sixel and text, and ends the wait for the68+// background of a terminal that doesn't say it.6669 func (t *terminal) probes() tea.Cmd {67- return tea.Raw(t.wrap(chart.Query()) + ansi.SetModeLightDark + paletteQuery() + sixelRegistersQuery + ansi.RequestPrimaryDeviceAttributes)70+ return tea.Raw(t.wrap(chart.Query()) + ansi.SetModeLightDark + ansi.RequestBackgroundColor + paletteQuery() + sixelRegistersQuery +71+ ansi.RequestPrimaryDeviceAttributes)6872 }6973 7074 // paletteQuery asks for the 16 ANSI colors; terminals that don't answer@@ -96,6 +100,7 @@ func (t *terminal) handle(msg tea.Msg) tea.Cmd {
96100 return tea.Raw(ansi.WindowOp(16)) // 16: report the cell size in pixels97101 }98102 case uv.PrimaryDeviceAttributesEvent:103+ t.waitBG = false // the background's answer, if any, came first99104 if slices.Contains(msg, 4) && !t.kitty && !t.tmux && !t.sixel {100105 t.sixel = true101106 return tea.Raw(ansi.WindowOp(16)) // sixel images are drawn at the cell size@@ -116,6 +121,8 @@ func (t *terminal) handle(msg tea.Msg) tea.Cmd {
116121 // The system switched between light and dark. The terminal's117122 // background decides the theme, so ask for it again.118123 return tea.RequestBackgroundColor124+ case bgWaitedMsg:125+ t.waitBG = false119126 case tea.FocusMsg:120127 t.blurred = false121128 case tea.BlurMsg:@@ -163,11 +163,12 @@ func New(s *sheet.Sheet, filename string) *Model {
163163 // serve's session span holds each session's commands.164164 func (m *Model) TraceUnder(p telemetry.Parent) { m.spans.Enter(p) }165165 166-// Init implements tea.Model. It asks the terminal for its background color167-// so the theme can adapt to light terminals.166+// Init implements tea.Model. It asks the terminal for its background167+// color, so the theme can adapt to light terminals, and holds the168+// screen until it knows (termbg.go).168169 func (m *Model) Init() tea.Cmd {169170 // jev.send starts any questions queued while loading the file.170- return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(m.spans.Parent()), m.startupCmd(), m.startStdin(), m.startOpenCmd(), m.startNotebook(), m.syncFollowers())171+ return tea.Batch(m.term.awaitBackground(), tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(m.spans.Parent()), m.startupCmd(), m.startStdin(), m.startOpenCmd(), m.startNotebook(), m.syncFollowers())171172 }172173 173174 // Update implements tea.Model.@@ -4,7 +4,6 @@ import (
44 "cmp"55 "slices"66 "strconv"7- "time"87 98 tea "charm.land/bubbletea/v2"109 @@ -23,11 +22,6 @@ import (
2322 // running them is nbrun.go's, sending an output to a sheet nbsend.go's,2423 // and drawing the tab nbscreen.go's.2524 26-// nbAfter is the clock notebooks time typing's pauses on27-// (nbview.View.After); nil is the real one. The tests' hands them the28-// pauses to end when they choose (pauses_test.go).29-var nbAfter func(time.Duration, tea.Msg) tea.Cmd30-3125 // nbKeys are command mode's keys, as Jupyter's; "d d" is d twice.3226 var nbKeys = map[string]string{3327 "enter": "nb.edit",@@ -195,7 +189,7 @@ func (m *Model) viewOf(s *sheet.Sheet) *nbview.View {
195189 v := m.nb.views[s]196190 if v == nil {197191 v = nbview.New(nbHost{m, s})198- v.Keys, v.EditKeys, v.After = nbKeys, nbEditKeys, nbAfter192+ v.Keys, v.EditKeys, v.After = nbKeys, nbEditKeys, after199193 lang := m.nbLang().For()200194 v.Providers = nbview.Providers{Highlighter: lang, Completer: lang, Checker: lang, Hoverer: lang}201195 m.nb.views[s] = v@@ -25,8 +25,8 @@ import (
2525 // the formula bar, and the context line. Below the grid, the status line2626 // shows the file and selection statistics.2727 28-// View implements tea.Model.29-func (m *Model) View() tea.View {28+// frame is the screen as the model is now (View).29+func (m *Model) frame() tea.View {3030 if telemetry.Enabled() {3131 defer m.timeFrame(time.Now())3232 }@@ -6,12 +6,17 @@ import (
66 tea "charm.land/bubbletea/v2"77 )88 9-// Notebooks ask about the text being edited, and the word at the caret,10-// once typing pauses. No test waits for a pause: the notebook's clock11-// hands the message the pause would give to run, which keeps it in12-// pauses, and a test that wants a pause over ends it with endPauses.9+// No unit test waits on the clock (after). There's no terminal to say10+// its background, so the model's wait for it is over at once; notebooks11+// ask about the text being edited, and the word at the caret, once12+// typing pauses, and the clock hands the message the pause would give13+// to run, which keeps it in pauses: a test that wants a pause over ends14+// it with endPauses.1315 func init() {14- nbAfter = func(_ time.Duration, msg tea.Msg) tea.Cmd {16+ after = func(_ time.Duration, msg tea.Msg) tea.Cmd {17+ if _, ok := msg.(bgWaitedMsg); ok {18+ return func() tea.Msg { return msg }19+ }1520 return func() tea.Msg { return pausedMsg{msg} }1621 }1722 }@@ -225,7 +225,7 @@ func (m *Model) handlePrefs(msg tea.Msg) bool {
225225 switch msg := msg.(type) {226226 case tea.BackgroundColorMsg:227227 m.prefs.light = !msg.IsDark()228- m.term.bg = msg.Color228+ m.term.bg, m.term.waitBG = msg.Color, false229229 if p := m.applyTheme(); p != "" && m.note == "" {230230 m.note = m.th.Warning.Render(p)231231 }@@ -219,3 +219,33 @@ func TestChartImagesOff(t *testing.T) {
219219 t.Error("images drawn although chart-images = false")220220 }221221 }222+223+// The screen stays blank until the terminal says its background, so a224+// light terminal never shows a frame in the dark theme; a terminal that225+// doesn't say it answers the device attributes asked after it, and one226+// that answers nothing is waited for only so long.227+func TestScreenWaitsForTheBackground(t *testing.T) {228+ blank := func(m *Model) bool { return strings.TrimSpace(screen(m)) == "" }229+ m, _, _ := configured(t, "theme = light:Builtin Solarized Light,dark:Dracula\n")230+ m.term.awaitBackground()231+ if !blank(m) {232+ t.Fatalf("drawn before the background is known:\n%s", screen(m))233+ }234+ send(m, tea.BackgroundColorMsg{Color: ansi.White})235+ if blank(m) || m.th.Name != "Builtin Solarized Light" {236+ t.Errorf("after a light background: theme %q, screen\n%s", m.th.Name, screen(m))237+ }238+239+ m = newModel()240+ m.term.awaitBackground()241+ send(m, uv.PrimaryDeviceAttributesEvent{62, 22})242+ if blank(m) {243+ t.Error("still blank after the device attributes")244+ }245+ m = newModel()246+ m.term.awaitBackground()247+ send(m, bgWaitedMsg{})248+ if blank(m) {249+ t.Error("still blank after waiting")250+ }251+}@@ -0,0 +1,53 @@
1+package ui2+3+import (4+ "time"5+6+ tea "charm.land/bubbletea/v2"7+)8+9+// The theme follows the terminal's background: dark on a dark one,10+// light on a light one (prefs.go). Until the terminal has said which,11+// the screen stays blank rather than draw a frame in the dark theme a12+// light terminal would flash, for as long as a question takes to cross13+// an SSH connection. The wait ends with the background's answer, with14+// the answer to the device attributes asked after it (probes), which a15+// terminal that doesn't say its background sends instead, or after16+// bgPatience for one that answers neither.17+18+// bgPatience is how long the screen waits for a terminal that answers19+// nothing.20+const bgPatience = 500 * time.Millisecond21+22+// bgWaitedMsg is bgPatience over.23+type bgWaitedMsg struct{}24+25+// after is the clock the model's waits are timed on: a command that26+// gives msg once d has passed; tea.Tick's when nil. Unit tests give one27+// that holds notebooks' pauses for them to end (pauses_test.go).28+var after func(d time.Duration, msg tea.Msg) tea.Cmd29+30+// wait gives msg once d has passed, on the model's clock.31+func wait(d time.Duration, msg tea.Msg) tea.Cmd {32+ if after != nil {33+ return after(d, msg)34+ }35+ return tea.Tick(d, func(time.Time) tea.Msg { return msg })36+}37+38+// awaitBackground holds the screen until the terminal says its39+// background; probes asks.40+func (t *terminal) awaitBackground() tea.Cmd {41+ t.waitBG = true42+ return wait(bgPatience, bgWaitedMsg{})43+}44+45+// View implements tea.Model: the screen, once the theme is known.46+func (m *Model) View() tea.View {47+ if m.term.waitBG {48+ v := tea.NewView(" ") // an empty view is drawn again every frame49+ v.AltScreen = true50+ return v51+ }52+ return m.frame()53+}