1package ui23import (4 "strings"5 "testing"67 "src.rest/cat/012/internal/sheet"8 "src.rest/cat/012/internal/ui/theme"9)1011// Every state reads without color (docs/contributing/ux.md#visual-rules):12// drawn with a monochrome theme, where every role has the same color,13// each state still differs from a plain cell in its text or attributes.1415// attrs are the attributes a terminal cell is drawn with, colors aside.16type attrs struct {17 bold, faint, italic, reverse, strike bool18 underline string // "" none, or the SGR 4 sub-parameter: 1 single, 3 curly, 4 dotted, 5 dashed19}2021// monoCell is one terminal cell: its text and attributes.22type monoCell struct {23 text string24 attrs25}2627// monoCells splits a rendered line into cells, reading SGR attributes28// and skipping colors and other escape sequences.29func monoCells(line string) []monoCell {30 var out []monoCell31 var a attrs32 for i := 0; i < len(line); {33 switch {34 case strings.HasPrefix(line[i:], "\x1b["):35 j := i + 236 for j < len(line) && (line[j] < 0x40 || line[j] > 0x7e) {37 j++38 }39 if j < len(line) && line[j] == 'm' {40 sgr(line[i+2:j], &a)41 }42 i = j + 143 case strings.HasPrefix(line[i:], "\x1b]"): // OSC, e.g. a hyperlink, up to ST or BEL44 end := strings.Index(line[i:], "\x1b\\")45 bel := strings.IndexByte(line[i:], '\a')46 switch {47 case bel >= 0 && (end < 0 || bel < end):48 i += bel + 149 case end >= 0:50 i += end + 251 default:52 i = len(line)53 }54 default:55 r := []rune(line[i:])[0]56 out = append(out, monoCell{text: string(r), attrs: a})57 i += len(string(r))58 }59 }60 return out61}6263// sgr applies one SGR sequence's parameters to a.64func sgr(params string, a *attrs) {65 fields := strings.Split(params, ";")66 for i := 0; i < len(fields); i++ {67 p, sub, _ := strings.Cut(fields[i], ":")68 switch p {69 case "", "0":70 *a = attrs{}71 case "1":72 a.bold = true73 case "2":74 a.faint = true75 case "3":76 a.italic = true77 case "4":78 a.underline = sub79 if sub == "" {80 a.underline = "1"81 } else if sub == "0" {82 a.underline = ""83 }84 case "7":85 a.reverse = true86 case "9":87 a.strike = true88 case "22":89 a.bold, a.faint = false, false90 case "23":91 a.italic = false92 case "24":93 a.underline = ""94 case "27":95 a.reverse = false96 case "29":97 a.strike = false98 case "38", "48", "58":99 if sub == "" && i+1 < len(fields) {100 switch fields[i+1] {101 case "5":102 i += 2103 case "2":104 i += 4105 }106 }107 }108 }109}110111// monoLine is screen line y drawn in monochrome, as cells.112func monoLine(m *Model, y int) []monoCell {113 m.th = theme.Monochrome(m.th)114 return monoCells(strings.Split(m.View().Content, "\n")[y])115}116117// cellAt is the terminal cells of sheet cell a (in the first screen of118// an unscrolled sheet).119func cellAt(m *Model, a sheet.Addr) []monoCell {120 x := m.hdrW() + a.Col*sheet.DefaultWidth121 cells := monoLine(m, gridTop+a.Row)122 return cells[x : x+sheet.DefaultWidth]123}124125func cellText(cs []monoCell) string {126 var b strings.Builder127 for _, c := range cs {128 b.WriteString(c.text)129 }130 return b.String()131}132133// every reports whether f holds for every cell of cs; with text, only for134// those that aren't blank.135func every(cs []monoCell, text bool, f func(monoCell) bool) bool {136 for _, c := range cs {137 if text && strings.TrimSpace(c.text) == "" {138 continue139 }140 if !f(c) {141 return false142 }143 }144 return true145}146147func isReverse(c monoCell) bool { return c.reverse }148func plain(c monoCell) bool { return c.attrs == attrs{} }149150func TestMonochromePointer(t *testing.T) {151 m := newModel()152 m.sheet.Set(addr("B1"), "text")153 if p := cellAt(m, addr("A1")); !every(p, false, isReverse) {154 t.Errorf("the pointer isn't in reverse video: %+v", p)155 }156 if c := cellAt(m, addr("B1")); !every(c, false, plain) {157 t.Errorf("a plain cell has attributes: %+v", c)158 }159}160161func TestMonochromeSelection(t *testing.T) {162 m := newModel()163 press(t, m, "<shift+right>", "<shift+down>")164 for _, a := range []string{"A1", "B1", "A2", "B2"} {165 if c := cellAt(m, addr(a)); !every(c, false, isReverse) {166 t.Errorf("%s in the selection isn't in reverse video", a)167 }168 }169 if c := cellAt(m, addr("C3")); !every(c, false, plain) {170 t.Error("a cell outside the selection has attributes")171 }172 // Column headers: the active cell's (the selection's anchor, A1)173 // bold and reversed, another selected one reversed, the rest plain.174 hdr := monoLine(m, headerLine)175 x := m.hdrW()176 col := func(c int) []monoCell { return hdr[x+c*sheet.DefaultWidth : x+(c+1)*sheet.DefaultWidth] }177 if !every(col(0), true, func(c monoCell) bool { return c.reverse && c.bold }) {178 t.Errorf("the active column's header: %+v", col(0))179 }180 if !every(col(1), true, func(c monoCell) bool { return c.reverse && !c.bold }) {181 t.Errorf("a selected column's header: %+v", col(1))182 }183 if every(col(2), true, isReverse) {184 t.Error("an unselected column's header is reversed")185 }186 if !strings.Contains(cellText(monoLine(m, formulaLine)), "A1:B2") {187 t.Error("the name box doesn't name the selection")188 }189}190191// findLine is the first screen line holding text, drawn in monochrome.192func findLine(t *testing.T, m *Model, text string) []monoCell {193 t.Helper()194 for y := range m.height {195 if l := monoLine(m, y); strings.Contains(cellText(l), text) {196 return l197 }198 }199 t.Fatalf("no line holds %q:\n%s", text, screen(m))200 return nil201}202203// cellsOf are the cells of line l holding text.204func cellsOf(l []monoCell, text string) []monoCell {205 i := strings.Index(cellText(l), text)206 return l[len([]rune(cellText(l)[:i])):][:len([]rune(text))]207}208209// A notebook's states read without color: the active cell marked by ▌210// at its left, the other cells selected with it by ▎, the cell being211// edited in a heavy box, the mode in words, an output's grid entered by212// its bar, OUTPUT and its pointer, a stale output and a failure in words.213func TestMonochromeNotebook(t *testing.T) {214 m, nu := notebookModel(t, map[string]string{"ls": lsOut, "$files | first": "{name: a.txt}"})215 m.height = 30216 write(t, m, "files = ls")217 write(t, m, "$files | first")218 write(t, m, "nope")219 run(m, m.runCommand("nb.run_all"))220 press(t, m, "<home>")221 bar := func(text string) string { return string([]rune(cellText(findLine(t, m, text)))[:1]) }222 if bar("[1]:") != "▌" || bar("[2]:") != " " {223 t.Errorf("the active cell's bar: %q %q", bar("[1]:"), bar("[2]:"))224 }225 press(t, m, "<shift+down>", "<shift+down>")226 if bar("[1]:") != "▎" || bar("[2]:") != "▎" || bar("[3]:") != "▌" {227 t.Errorf("the cells selected: %q %q %q", bar("[1]:"), bar("[2]:"), bar("[3]:"))228 }229 press(t, m, "<home>", "<down>", "<enter>")230 if c := cellsOf(findLine(t, m, "a.txt"), "a.txt"); !every(c, true, isReverse) {231 t.Errorf("the active cell of an output's grid: %+v", c)232 }233 if !strings.Contains(cellText(monoLine(m, menuLine)), " OUTPUT ") || bar("1 a.txt") != "▌" {234 t.Error("working in an output isn't in words")235 }236 press(t, m, "<esc>", "<end>")237 if !strings.Contains(cellText(findLine(t, m, "× Command")), "× Command `nope` not found") {238 t.Error("the failure isn't in words")239 }240 press(t, m, "<home>")241 nu.out["ls"] = "[[name]; [z]]"242 press(t, m, "<ctrl+enter>")243 findLine(t, m, "stale")244 press(t, m, "<enter>")245 if !strings.Contains(cellText(monoLine(m, menuLine)), " EDIT ") {246 t.Error("edit mode isn't in words")247 }248 findLine(t, m, "┏━")249}250251func TestMonochromeError(t *testing.T) {252 m := newModel()253 m.sheet.Set(addr("B1"), "=1/0")254 c := cellAt(m, addr("B1"))255 if !strings.Contains(cellText(c), "#DIV/0!") || !every(c, true, func(c monoCell) bool { return c.underline == "3" }) {256 t.Errorf("an error cell: %q %+v", cellText(c), c)257 }258}259260func TestMonochromeInvalid(t *testing.T) {261 m := newModel()262 mustValidate(t, m, `{"ranges":"B1:B9","criteria":"number","condition":"between","values":["1","10"]}`)263 m.sheet.Set(addr("B1"), "42")264 c := cellAt(m, addr("B1"))265 if !every(c, true, func(c monoCell) bool { return c.underline == "4" }) {266 t.Errorf("an invalid cell isn't dotted: %+v", c)267 }268 m.cur = addr("B1")269 if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "Invalid:") {270 t.Errorf("no warning in words: %q", l)271 }272}273274func TestMonochromeCircular(t *testing.T) {275 m := newModel()276 m.sheet.Set(addr("A1"), "=A1+1")277 if l := cellText(monoLine(m, m.height-1)); !strings.Contains(l, "Circular reference") {278 t.Errorf("status line: %q", l)279 }280}281282func TestMonochromeSpill(t *testing.T) {283 m := newModel()284 m.sheet.Set(addr("B1"), "=SEQUENCE(3)")285 m.cur = addr("D1")286 c := cellAt(m, addr("B2"))287 if strings.TrimSpace(cellText(c)) != "2" || !every(c, true, func(c monoCell) bool { return c.italic }) {288 t.Errorf("a spilled value isn't italic: %+v", c)289 }290 m.cur = addr("B2")291 if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "Spilled from B1") {292 t.Errorf("context line: %q", l)293 }294}295296func TestMonochromeLinked(t *testing.T) {297 m := newModel()298 id, _ := m.sheet.AddLinked(addr("B1"), sheet.LinkSource{Path: "log.csv"})299 m.book().ApplyLive(sheet.LiveOp{Region: id, Reset: true, Header: sheet.LiveRow{{V: sheet.Value{Kind: sheet.Text, Str: "n"}}},300 Rows: []sheet.LiveRow{{{V: sheet.Value{Kind: sheet.Number, Num: 2}}}}})301 m.cur = addr("D1")302 c := cellAt(m, addr("B2"))303 if strings.TrimSpace(cellText(c)) != "2" || !every(c, true, func(c monoCell) bool { return c.italic }) {304 t.Errorf("a linked value isn't italic: %+v", c)305 }306 if l := cellText(monoLine(m, m.height-1)); !strings.Contains(l, "Sheet1 ●") {307 t.Errorf("status line: %q", l)308 }309 m.cur = addr("B2")310 if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "● Following log.csv") {311 t.Errorf("context line: %q", l)312 }313 m.book().PauseLinked(id, true)314 if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "‖ Paused log.csv") {315 t.Errorf("context line: %q", l)316 }317 m.book().ApplyLive(sheet.LiveOp{Region: id, Err: "the file isn't there"})318 if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "! log.csv: the file isn't there") {319 t.Errorf("context line: %q", l)320 }321}322323func TestMonochromeNote(t *testing.T) {324 m := newModel()325 m.sheet.Set(addr("B1"), "x")326 m.sheet.SetNote(addr("B1"), "Checked")327 if c := cellAt(m, addr("B1")); c[len(c)-1].text != "▝" {328 t.Errorf("no note mark: %q", cellText(c))329 }330}331332func TestMonochromeFoundTraced(t *testing.T) {333 m := traceModel(t)334 m.cur = addr("B1")335 press(t, m, "<alt+,>")336 if c := cellAt(m, addr("A2")); !every(c, false, isReverse) {337 t.Errorf("a traced cell isn't reversed: %+v", c)338 }339 // Tracing on: precedents reversed, dependents reversed and bold.340 press(t, m, "<esc>", "<alt+;>")341 if c := cellAt(m, addr("A2")); !every(c, false, isReverse) || every(c, true, func(c monoCell) bool { return c.bold }) {342 t.Errorf("a precedent isn't reversed alone: %+v", c)343 }344 if c := cellAt(m, addr("C1")); !every(c, false, func(c monoCell) bool { return c.reverse && c.bold }) {345 t.Errorf("a dependent isn't reversed and bold: %+v", c)346 }347 m = newModel()348 m.sheet.Set(addr("B2"), "Rent")349 press(t, m, "<ctrl+f>", "Rent")350 if c := cellAt(m, addr("B2")); !every(c, false, isReverse) {351 t.Errorf("a match isn't reversed: %+v", c)352 }353}354355func TestMonochromeCopied(t *testing.T) {356 m := newModel()357 m.sheet.Set(addr("A1"), "1")358 press(t, m, "<ctrl+c>", "<right>")359 c := cellAt(m, addr("A1"))360 if !every(c, false, func(c monoCell) bool { return c.underline != "" }) || !every(c, true, func(c monoCell) bool { return c.underline == "5" }) {361 t.Errorf("the copied range isn't underlined, dashed under its text: %+v", c)362 }363}364365// States told in words: the mode indicator, recording, protected ranges366// and pivot tables.367func TestMonochromeStatesInWords(t *testing.T) {368 m := newModel()369 if l := cellText(monoLine(m, menuLine)); !strings.Contains(l, "READY") {370 t.Errorf("indicator: %q", l)371 }372 press(t, m, "1")373 if l := cellText(monoLine(m, menuLine)); !strings.Contains(l, "ENTER") {374 t.Errorf("indicator while typing: %q", l)375 }376 press(t, m, "<esc>")377 run(m, m.runCommand("macro.record"))378 if l := cellText(monoLine(m, menuLine)); !strings.Contains(l, "REC") {379 t.Errorf("recording: %q", l)380 }381382 m = newModel()383 press(t, m, "<shift+down>")384 m.runCommand("data.protect_range")385 press(t, m, "<enter>", "<esc>", "7")386 if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "is protected") {387 t.Errorf("protected: %q", l)388 }389390 m = wideModel()391 salesTable(t, m)392 m.runCommand("data.pivot")393 press(t, m, "<space>", "reg", "<enter>", "<enter>", "<ctrl+home>", "x")394 if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "Pivot table results can't be edited") {395 t.Errorf("pivot: %q", l)396 }397}398399// Wrapping, borders and merged cells are drawn in characters: they read400// in monochrome as they do in color, and the pointer on a merged cell401// reverses all of it.402func TestMonochromeLayout(t *testing.T) {403 m := newModel()404 m.sheet.Set(addr("A2"), "one two three")405 m.sheet.SetStyle(rect("A2"), func(st *sheet.Style) { st.Wrap = sheet.WrapOn })406 m.sheet.SetBorders(rect("A2:B2"), sheet.BorderAll, sheet.LineThin)407 m.sheet.Set(addr("C1"), "merged")408 m.sheet.Merge(rect("C1:D1"), sheet.MergeAll)409 m.cur = addr("C1")410 y, _ := m.rowY(1)411 x := m.hdrW()412 rule := cellText(monoLine(m, y-1)[x : x+2*sheet.DefaultWidth+1])413 if rule != "┌─────────┬─────────┐" {414 t.Errorf("the rule above row 2: %q", rule)415 }416 var text []string417 for k := range m.shape(1).Lines {418 text = append(text, strings.TrimSpace(cellText(monoLine(m, y+k)[x:x+sheet.DefaultWidth])))419 }420 if strings.Join(text, "|") != "│one two|│three" {421 t.Errorf("wrapped text: %q", text)422 }423 merged := monoLine(m, gridTop)[x+2*sheet.DefaultWidth : x+4*sheet.DefaultWidth]424 if !every(merged, false, isReverse) || !strings.Contains(cellText(merged), "merged") {425 t.Errorf("the pointer on a merged cell: %q %+v", cellText(merged), merged)426 }427}428429// Border colors are colors the user chose and stay colors; without430// color their lines are the same characters. A value aligned to the top431// of a tall row reads there.432func TestMonochromeBorderColorAndVAlign(t *testing.T) {433 m := newModel()434 m.sheet.Set(addr("A2"), "top")435 m.sheet.SetRowHeight(1, 1, 3)436 m.sheet.SetStyle(rect("A2"), func(st *sheet.Style) { st.VAlign = sheet.VAlignTop })437 m.sheet.SetBorderStroke(rect("A2:B2"), sheet.BorderOuter, sheet.Stroke{Line: sheet.LineThick, Color: sheet.ColorRed})438 y, _ := m.rowY(1)439 x := m.hdrW()440 if rule := cellText(monoLine(m, y-1)[x : x+2*sheet.DefaultWidth+1]); rule != "┏━━━━━━━━━━━━━━━━━━━┓" {441 t.Errorf("the red outline: %q", rule)442 }443 if got := strings.TrimSpace(cellText(monoLine(m, y)[x : x+sheet.DefaultWidth])); got != "┃top" {444 t.Errorf("the first line of row 2: %q", got)445 }446}447448// Data bars, icons and dropdown chips read without color: blocks as449// long as the number, text under a bar and a chip in reverse video, an450// icon's glyph.451func TestMonochromeBarsIconsChips(t *testing.T) {452 m := newModel()453 m.sheet.Set(addr("A1"), "50")454 m.sheet.Set(addr("A2"), "100")455 m.sheet.Set(addr("B1"), "1")456 m.sheet.Set(addr("B2"), "9")457 m.sheet.Set(addr("C1"), "Yes")458 mustFormat(t, m, `{"ranges":"A1:A2","dataBar":{"color":"green","min":{"type":"min"},"max":{"type":"max"}}}`)459 mustFormat(t, m, `{"ranges":"B1:B2","iconSet":{"icons":"symbols","points":[{"type":"percent","value":"33"},{"type":"percent","value":"67"}]}}`)460 mustValidate(t, m, `{"ranges":"C1","criteria":"list","items":["Yes","No"],"display":"chip"}`)461 m.cur = addr("E5")462 a1 := cellAt(m, addr("A1"))463 if !strings.HasPrefix(cellText(a1), "████") {464 t.Errorf("A1's bar %q", cellText(a1))465 }466 a2 := cellAt(m, addr("A2"))467 if got := cellText(a2); !strings.Contains(got, "100") || !a2[7].reverse {468 t.Errorf("A2 under its bar %q %+v", got, a2[7])469 }470 if b1, b2 := cellText(cellAt(m, addr("B1"))), cellText(cellAt(m, addr("B2"))); !strings.HasPrefix(b1, " ✗") || !strings.HasPrefix(b2, " ✓") {471 t.Errorf("icons %q %q", b1, b2)472 }473 c1 := cellAt(m, addr("C1"))474 if got := cellText(c1); !strings.HasPrefix(got, " ▐Yes ▾▌") || !c1[2].reverse {475 t.Errorf("chip %q %+v", got, c1[2])476 }477}478479// A problem nu finds in the cell being written is underlined, curly480// as an error cell is, and in words on the context line at the caret.481func TestMonochromeNuProblem(t *testing.T) {482 nu := &ideNu{fakeNu: &fakeNu{}, problem: "--revrse"}483 m := newModel()484 m.SetShellRunner(nu)485 m.SetMachine("here")486 run(m, m.runCommand("nb.open"))487 press(t, m, "b", "<enter>")488 send(m, pasteMsg("ls | sort-by name --revrse"))489 endPauses(m)490 l := findLine(t, m, "sort-by")491 if c := cellsOf(l, "--revrse"); !every(c, true, func(c monoCell) bool { return c.underline == "3" }) {492 t.Errorf("the problem isn't underlined: %+v", c)493 }494 if c := cellsOf(l, "name"); every(c, true, func(c monoCell) bool { return c.underline == "3" }) {495 t.Error("what's fine is underlined")496 }497 if !strings.Contains(cellText(monoLine(m, contextLine)), "Not a flag.") {498 t.Errorf("the context line: %q", cellText(monoLine(m, contextLine)))499 }500}