diff --git a/tools/cli/wcswidth_kitten.go b/tools/cli/wcswidth_kitten.go index fdbcb0c55..c49e021ac 100644 --- a/tools/cli/wcswidth_kitten.go +++ b/tools/cli/wcswidth_kitten.go @@ -10,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "kitty" + "kitty/tools/tty" "kitty/tools/tui/loop" "kitty/tools/utils" "kitty/tools/utils/style" @@ -18,34 +19,78 @@ import ( var _ = fmt.Print +type cpos struct { + x, y int +} + +func cpos_from_report(csi string) (ans cpos, err error) { + before, after, found := strings.Cut(csi, ";") + if !found { + return ans, fmt.Errorf("Malformed Cursor Position Report from terminal with no ;") + } + if ans.y, err = strconv.Atoi(before); err != nil { + return ans, fmt.Errorf("Malformed Cursor Position Report from terminal: %s", csi) + } + if ans.x, err = strconv.Atoi(after); err != nil { + return ans, fmt.Errorf("Malformed Cursor Position Report from terminal: %s", csi) + } + // convert 1-based indexing to zero based indexing + ans.x-- + ans.y-- + return +} + type test_struct struct { description string num int expected_cursor_positions []int - actual_cursor_positions []int + actual_cursor_positions []cpos payload string + tester func(actual_cursor_positions []cpos, screen_width int) string + payload_gen func(width_in_cells int) string } const cursor_position_report = "\x1b[6n" const reset_line = "\r\x1b[K" +func wrap_gen(width_in_cells int) string { + return strings.Repeat(" ", width_in_cells-2) + "\U0001f1ee" + cursor_position_report + "\U0001f1f3" + cursor_position_report +} + +func wrap_tester(actual_cursor_positions []cpos, screen_width int) string { + if actual_cursor_positions[0].x != actual_cursor_positions[1].x { + return fmt.Sprintf("The cursor moved after adding a combining char from: %v -> %v", actual_cursor_positions[0], actual_cursor_positions[1]) + } + return "" +} + func run_tests(tests []*test_struct) (err error) { lp, err := loop.New(loop.NoAlternateScreen) if err != nil { return err } - buf := strings.Builder{} - buf.WriteString(loop.PENDING_UPDATE.EscapeCodeToSet()) - for _, t := range tests { - buf.WriteString(t.payload) - buf.WriteString(reset_line) - if buf.Len() > 512*1024 { - buf.WriteString(loop.PENDING_UPDATE.EscapeCodeToReset()) - buf.WriteString(loop.PENDING_UPDATE.EscapeCodeToSet()) + num_reports := 0 + expected_num_reports := 0 + gen_payload := func(screen_width int) string { + buf := strings.Builder{} + buf.WriteString(loop.PENDING_UPDATE.EscapeCodeToSet()) + for _, t := range tests { + payload := t.payload + if t.payload_gen != nil { + payload = t.payload_gen(screen_width) + } + expected_num_reports += strings.Count(payload, cursor_position_report) + buf.WriteString(payload) + buf.WriteString(reset_line) + if buf.Len() > 512*1024 { + buf.WriteString(loop.PENDING_UPDATE.EscapeCodeToReset()) + buf.WriteString(loop.PENDING_UPDATE.EscapeCodeToSet()) + } } + buf.WriteString(loop.PENDING_UPDATE.EscapeCodeToReset()) + buf.WriteString("\x1b[c") + return buf.String() } - buf.WriteString(loop.PENDING_UPDATE.EscapeCodeToReset()) - buf.WriteString("\x1b[c") print_para := func(text string) { sz, _ := lp.ScreenSize() @@ -54,17 +99,23 @@ func run_tests(tests []*test_struct) (err error) { } lp.Println() } + screen_width := 80 lp.OnInitialize = func() (string, error) { + sz, _ := lp.ScreenSize() + screen_width = int(sz.WidthCells) + lp.SaveCursorPosition() lp.SetCursorVisible(false) print_para("These tests work by sending text to the terminal and then querying it for its cursor position. Every test is thus different strings sent to the terminal along with a list of expected cursor positions after each string. A failure means the actual cursor position was different from the expected one. A failure where the first expected cursor position is correct but subsequent ones are not, means that the complete string was rendered at the correct width but individual graphemes from the string were not.") print_para("The individual test descriptions use the character ÷ to indicate a position where a break is expected to occur and the character × to indicate a position where no break should happen. ") lp.Printf("Running %d tests, please wait...\n", len(tests)) - lp.QueueWriteString(buf.String()) + lp.QueueWriteString(gen_payload(screen_width)) return "", err } lp.OnFinalize = func() string { lp.SetCursorVisible(true) + lp.RestoreCursorPosition() + lp.ClearToEndOfScreen() return "" } current_test_idx := 0 @@ -75,13 +126,16 @@ func run_tests(tests []*test_struct) (err error) { lp.Quit(0) case 'R': if idx := bytes.IndexByte(data, ';'); idx > -1 { - if xpos, e := strconv.Atoi(utils.UnsafeBytesToString(data[idx+1 : len(data)-1])); e == nil { + if cpos, err := cpos_from_report(utils.UnsafeBytesToString(data[:len(data)-1])); err != nil { + return err + } else { + num_reports++ t := tests[current_test_idx] if len(t.actual_cursor_positions) >= len(t.expected_cursor_positions) && current_test_idx+1 < len(tests) { current_test_idx += 1 t = tests[current_test_idx] } - t.actual_cursor_positions = append(t.actual_cursor_positions, xpos-1) + t.actual_cursor_positions = append(t.actual_cursor_positions, cpos) } } } @@ -91,13 +145,26 @@ func run_tests(tests []*test_struct) (err error) { if err = lp.Run(); err != nil { return err } - return show_results(tests) + if num_reports != expected_num_reports { + return fmt.Errorf("Terminal did not report the cursor position as many times as expected. %d != %d", expected_num_reports, num_reports) + } + return show_results(tests, screen_width) } -func show_results(tests []*test_struct) (err error) { +func show_results(tests []*test_struct, screen_width int) (err error) { num_failures := 0 for _, t := range tests { - if diff := cmp.Diff(t.expected_cursor_positions, t.actual_cursor_positions); diff != "" { + diff := "" + if t.tester == nil { + ac := make([]int, len(t.actual_cursor_positions)) + for i, cp := range t.actual_cursor_positions { + ac[i] = cp.x + } + diff = cmp.Diff(t.expected_cursor_positions, ac) + } else { + diff = t.tester(t.actual_cursor_positions, screen_width) + } + if diff != "" { fmt.Printf("\x1b[31mTest number %d failed\x1b[39m: %s\n", t.num, t.description) fmt.Println(diff) num_failures++ @@ -120,29 +187,47 @@ func has_control_chars(text string) bool { return false } -func main(allowed_tests *utils.Set[int]) (rc int, err error) { - var tests []*test_struct - if gb_tests, err := kitty.LoadGraphemeBreakTests(); err == nil { - for _, t := range gb_tests { - text := strings.Join(t.Data, "") - rt, _ := json.Marshal(text) - desc := fmt.Sprintf("Unicode GraphemeBreakTest: Text: %s Expected breaks:\n%s", string(rt), t.Comment) - if has_control_chars(text) { - continue - } - buf := strings.Builder{} - buf.WriteString(" " + text + cursor_position_report + reset_line + " ") - expected_cursor_positions := []int{1 + wcswidth.Stringwidth(text)} - // Now test cursor position after each individual grapheme - pos := 0 - for _, grapheme := range t.Data { - buf.WriteString(grapheme + cursor_position_report) - pos += wcswidth.Stringwidth(grapheme) - expected_cursor_positions = append(expected_cursor_positions, 1+pos) - } - test := test_struct{num: len(tests) + 1, description: desc, payload: buf.String(), expected_cursor_positions: expected_cursor_positions} - tests = append(tests, &test) +func create_tests(gb_tests []kitty.GraphemeBreakTest, width_in_cells int) (tests []*test_struct) { + for _, t := range gb_tests { + text := strings.Join(t.Data, "") + rt, _ := json.Marshal(text) + desc := fmt.Sprintf("Unicode GraphemeBreakTest: Text: %s Expected breaks:\n%s", string(rt), t.Comment) + if has_control_chars(text) { + continue } + buf := strings.Builder{} + buf.WriteString(" " + text + cursor_position_report + reset_line + " ") + expected_cursor_positions := []int{1 + wcswidth.Stringwidth(text)} + // Now test cursor position after each individual grapheme + pos := 0 + for _, grapheme := range t.Data { + buf.WriteString(grapheme + cursor_position_report) + pos += wcswidth.Stringwidth(grapheme) + expected_cursor_positions = append(expected_cursor_positions, 1+pos) + } + test := test_struct{num: len(tests) + 1, description: desc, payload: buf.String(), expected_cursor_positions: expected_cursor_positions} + tests = append(tests, &test) + } + test := test_struct{ + num: len(tests) + 1, description: "Check that combining characters are merged into last cell even when cursor is on the next line", + payload_gen: wrap_gen, tester: wrap_tester} + tests = append(tests, &test) + return +} + +func main(allowed_tests *utils.Set[int]) (rc int, err error) { + term, err := tty.OpenControllingTerm() + if err != nil { + return 1, fmt.Errorf("Could not open controlling terminal with error: %w", err) + } + sz, err := term.GetSize() + term.Close() + if err != nil { + return 1, fmt.Errorf("Could not get size of controlling terminal with error: %w", err) + } + width_in_cells := int(sz.Col) + if gb_tests, err := kitty.LoadGraphemeBreakTests(); err == nil { + tests := create_tests(gb_tests, width_in_cells) if allowed_tests.Len() > 0 { temp := make([]*test_struct, 0, len(tests)) for _, t := range tests {