#!/usr/bin/env python
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>

from kitty.fast_data_types import DECAWM, DECCOLM, DECOM, IRM, VT_PARSER_BUFFER_SIZE, Color, ColorProfile, Cursor
from kitty.marks import marker_from_function, marker_from_regex, marker_from_text
from kitty.window import pagerhist

from . import BaseTest, draw_multicell, parse_bytes


class TestScreen(BaseTest):

    def test_draw_fast(self):
        s = self.create_screen()

        # Test in line-wrap, non-insert mode
        s.draw('a' * 5)
        self.ae(str(s.line(0)), 'a' * 5)
        self.ae(s.cursor.x, 5), self.ae(s.cursor.y, 0)
        s.draw('b' * 7)
        self.assertTrue(s.linebuf.is_continued(1))
        self.assertTrue(s.linebuf.is_continued(2))
        self.ae(str(s.line(0)), 'a' * 5)
        self.ae(str(s.line(1)), 'b' * 5)
        self.ae(str(s.line(2)), 'b' * 2)
        self.ae(s.cursor.x, 2), self.ae(s.cursor.y, 2)
        s.draw('c' * 15)
        self.ae(str(s.line(0)), 'b' * 5)
        self.ae(str(s.line(1)), 'bbccc')

        # Now test without line-wrap
        s.reset(), s.reset_dirty()
        s.reset_mode(DECAWM)
        s.draw('0123456789')
        self.ae(str(s.line(0)), '01239')
        self.ae(s.cursor.x, 5), self.ae(s.cursor.y, 0)
        s.draw('ab')
        self.ae(str(s.line(0)), '0123b')
        self.ae(s.cursor.x, 5), self.ae(s.cursor.y, 0)

        # Now test in insert mode
        s.reset(), s.reset_dirty()
        s.set_mode(IRM)
        s.draw('12345' * 5)
        s.cursor_move(5)
        self.ae(s.cursor.x, 0), self.ae(s.cursor.y, 4)
        s.reset_dirty()
        s.draw('ab')
        self.ae(str(s.line(4)), 'ab123')
        self.ae((s.cursor.x, s.cursor.y), (2, 4))

    def test_draw_char(self):
        # Test in line-wrap, non-insert mode
        s = self.create_screen()
        s.draw('ココx')
        self.ae(str(s.line(0)), 'ココx')
        self.ae(tuple(map(s.line(0).width, range(5))), (2, 0, 2, 0, 1))
        self.ae(s.cursor.x, 5), self.ae(s.cursor.y, 0)
        s.draw('ニチハ')
        self.ae(str(s.line(0)), 'ココx')
        self.ae(str(s.line(1)), 'ニチ')
        self.ae(str(s.line(2)), 'ハ')
        self.ae(s.cursor.x, 2), self.ae(s.cursor.y, 2)
        s.draw('Ƶ̧\u0308')
        self.ae(str(s.line(2)), 'ハƵ̧\u0308')
        self.ae(s.cursor.x, 3), self.ae(s.cursor.y, 2)
        s.draw('xy'), s.draw('\u0306')
        self.ae(str(s.line(2)), 'ハƵ̧\u0308xy\u0306')
        self.ae(s.cursor.x, 5), self.ae(s.cursor.y, 2)
        s.draw('c' * 15)
        self.ae(str(s.line(0)), 'ニチ')
        s.reset()
        qt = 'a' * s.columns + '\u0306'
        s.draw(qt)
        self.ae(str(s.line(0)), qt)
        s.reset()
        s.draw(qt[:-1]), s.draw(qt[-1])
        self.ae(str(s.line(0)), qt)
        s.reset()
        s.draw(qt[:-1]), s.linefeed(), s.carriage_return(), s.draw(qt[-1])
        self.ae(str(s.line(0)), qt[:-1])

        # Now test without line-wrap
        s.reset(), s.reset_dirty()
        s.reset_mode(DECAWM)
        s.draw('0\u030612345\u03066789\u0306')
        self.ae(str(s.line(0)), '0\u03061239\u0306')
        self.ae(s.cursor.x, 5), self.ae(s.cursor.y, 0)
        s.draw('ab\u0306')
        self.ae(str(s.line(0)), '0\u0306123b\u0306')
        self.ae(s.cursor.x, 5), self.ae(s.cursor.y, 0)

        # Now test in insert mode
        s.reset(), s.reset_dirty()
        text = '1\u03062345'
        s.set_mode(IRM)
        s.draw(text * 5)
        self.ae(str(s.line(0)), text)
        s.cursor_move(5)
        self.ae(s.cursor.x, 0), self.ae(s.cursor.y, 4)
        s.reset_dirty()
        s.draw('a\u0306b')
        self.ae(str(s.line(4)), 'a\u0306b1\u030623')
        self.ae((s.cursor.x, s.cursor.y), (2, 4))

        # Test drawing of tabs
        s = self.create_screen(cols=32)
        txt = 'a\tb'
        s.draw(txt)
        ln = s.line(0)
        self.ae(txt, ln.as_ansi())

    def test_rep(self):
        s = self.create_screen()
        s.draw('a')
        parse_bytes(s, b'\x1b[b')
        self.ae(str(s.line(0)), 'aa')
        parse_bytes(s, b'\x1b[3b')
        self.ae(str(s.line(0)), 'a'*5)
        s.draw(' ')
        parse_bytes(s, b'\x1b[3b')
        self.ae(str(s.line(1)), ' '*4)

    def test_emoji_skin_tone_modifiers(self):
        s = self.create_screen()
        q = chr(0x1f469) + chr(0x1f3fd)
        s.draw(q)
        self.ae(str(s.line(0)), q)
        self.ae(s.cursor.x, 2)

    def test_regional_indicators(self):
        s = self.create_screen()
        flag = '\U0001f1ee\U0001f1f3'
        s.draw(flag)
        self.ae(str(s.line(0)), flag)
        self.ae(s.cursor.x, 2)
        s = self.create_screen()
        s.draw('a'), s.draw(flag[0]), s.draw('b')
        self.ae(str(s.line(0)), 'a' + flag[0] + 'b')
        self.ae(s.cursor.x, 4)

    def test_zwj(self):
        s = self.create_screen(cols=20)
        q = '\U0001f468\u200d\U0001f469\u200d\U0001f467\u200d\U0001f466'
        s.draw(q)
        self.ae(q, str(s.line(0)))
        self.ae(s.cursor.x, 2)
        for x in '\u200b\u200c\u200d':
            s.reset()
            q = f'X{x}Y'
            s.draw(q)
            self.ae(q, str(s.line(0)))
            self.ae(s.cursor.x, 2)

    def test_char_manipulation(self):
        s = self.create_screen()

        def init():
            s.reset(), s.reset_dirty()
            s.draw('abcde')
            s.cursor.bold = True
            s.cursor_move(4)
            s.reset_dirty()
            self.ae(s.cursor.x, 1)

        init()
        s.insert_characters(2)
        self.ae(str(s.line(0)), 'a  bc')
        self.assertTrue(s.line(0).cursor_from(1).bold)
        s.cursor_move(1)
        s.insert_characters(20)
        self.ae(str(s.line(0)), '')
        s.draw('xココ')
        s.cursor_move(5)
        s.reset_dirty()
        s.insert_characters(1)
        self.ae(str(s.line(0)), ' xコ')
        c = Cursor()
        c.italic = True
        s.line(0).apply_cursor(c, 0, 5)
        self.ae(s.line(0).width(2), 2)
        self.assertTrue(s.line(0).cursor_from(2).italic)
        self.assertFalse(s.line(0).cursor_from(2).bold)

        init()
        s.delete_characters(2)
        self.ae(str(s.line(0)), 'ade')
        self.assertTrue(s.line(0).cursor_from(4).bold)
        self.assertFalse(s.line(0).cursor_from(2).bold)
        s = self.create_screen()
        s.set_margins(1, 2)
        s.cursor.y = 3
        s.draw('abcde')
        s.cursor.x = 0
        s.delete_characters(2)
        self.ae('cde', str(s.line(s.cursor.y)))

        init()
        s.erase_characters(2)
        self.ae(str(s.line(0)), 'a  de')
        self.assertTrue(s.line(0).cursor_from(1).bold)
        self.assertFalse(s.line(0).cursor_from(4).bold)
        s.erase_characters(20)
        self.ae(str(s.line(0)), 'a')

        init()
        s.erase_in_line()
        self.ae(str(s.line(0)), 'a')
        self.assertTrue(s.line(0).cursor_from(1).bold)
        self.assertFalse(s.line(0).cursor_from(0).bold)
        init()
        s.erase_in_line(1)
        self.ae(str(s.line(0)), '  cde')
        init()
        s.erase_in_line(2)
        self.ae(str(s.line(0)), '')
        init()
        s.erase_in_line(2, True)
        self.ae((False, False, False, False, False), tuple(map(lambda i: s.line(0).cursor_from(i).bold, range(5))))

    def test_erase_in_screen(self):
        s = self.create_screen()

        def init():
            s.reset()
            s.draw('12345' * 5)
            s.reset_dirty()
            s.cursor.x, s.cursor.y = 2, 1
            s.cursor.bold = True
            self.ae(continuations(s), (True, True, True, True, False))

        def all_lines(s):
            return tuple(str(s.line(i)) for i in range(s.lines))

        def continuations(s):
            return tuple(s.line(i).last_char_has_wrapped_flag() for i in range(s.lines))

        init()
        s.erase_in_display(0)
        self.ae(all_lines(s), ('12345', '12', '', '', ''))
        self.ae(continuations(s), (True, False, False, False, False))

        init()
        s.erase_in_display(1)
        self.ae(all_lines(s), ('', '   45', '12345', '12345', '12345'))
        self.ae(continuations(s), (False, True, True, True, False))

        init()
        s.erase_in_display(2)
        self.ae(all_lines(s), ('', '', '', '', ''))
        self.assertTrue(s.line(0).cursor_from(1).bold)
        self.ae(continuations(s), (False, False, False, False, False))

        init()
        s.erase_in_display(2, True)
        self.ae(all_lines(s), ('', '', '', '', ''))
        self.ae(continuations(s), (False, False, False, False, False))
        self.assertFalse(s.line(0).cursor_from(1).bold)

    def test_cursor_movement(self):
        s = self.create_screen()
        s.draw('12345' * 5)
        s.reset_dirty()
        s.cursor_up(2)
        self.ae((s.cursor.x, s.cursor.y), (4, 2))
        s.cursor_up1()
        self.ae((s.cursor.x, s.cursor.y), (0, 1))
        s.cursor_forward(3)
        self.ae((s.cursor.x, s.cursor.y), (3, 1))
        s.cursor_move()
        self.ae((s.cursor.x, s.cursor.y), (2, 1))
        s.cursor_down()
        self.ae((s.cursor.x, s.cursor.y), (2, 2))
        s.cursor_down1(5)
        self.ae((s.cursor.x, s.cursor.y), (0, 4))

        s = self.create_screen()
        s.draw('12345' * 5)
        s.index()
        self.ae(str(s.line(4)), '')
        for i in range(4):
            self.ae(str(s.line(i)), '12345')
        s.draw('12345' * 5)
        s.cursor_up(5)
        s.reverse_index()
        self.ae(str(s.line(0)), '')
        for i in range(1, 5):
            self.ae(str(s.line(i)), '12345')

    def test_backspace_wide_characters(self):
        s = self.create_screen()
        s.draw('⛅')
        self.ae(s.cursor.x, 2)
        s.backspace()
        s.draw(' ')
        s.backspace()
        self.ae(s.cursor.x, 1)

    def test_resize(self):
        from kitty.window import as_text
        def at():
            return as_text(s, add_history=True)
        def ac():
            return s.line(s.cursor.y)[s.cursor.x]

        # test that a wrapped line split by the history buffer is re-stitched
        s = self.create_screen(cols=4, lines=4, scrollback=4)
        text = ''
        for i in range(s.lines + 1):
            if i == 2:
                text += 'abcd'
            else:
                text += str(i + 1) * s.columns
        s.draw(text)
        self.assertTrue(s.historybuf.endswith_wrap())
        s.cursor.x, s.cursor.y = 1, 1
        self.ae(ac(), 'b')
        s.resize(s.lines, s.columns + 2)
        self.assertTrue(s.historybuf.endswith_wrap())
        self.ae(str(s.historybuf), '111122')
        self.ae(at(), text + '\n')
        self.ae((s.cursor.x, s.cursor.y), (3, 0))
        self.ae(ac(), 'b')
        s = self.create_screen(cols=4, lines=4, scrollback=4)
        s.draw('1111222'), s.linefeed(), s.carriage_return()
        s.draw('333344445555')
        s.resize(s.lines, s.columns + 2)
        self.ae(str(s.historybuf), '111122')
        self.ae(str(s.line(0)), '2')
        self.ae(at(), '1111222\n333344445555\n')
        s = self.create_screen(cols=4, lines=4, scrollback=4)
        s.draw('1111😸2'), s.linefeed(), s.carriage_return()
        s.index(), s.index()
        s.resize(s.lines, s.columns + 1)
        self.ae(str(s.historybuf), '1111')
        self.assertTrue(s.historybuf.endswith_wrap())
        self.ae(at(), '1111😸2\n\n\n')
        s = self.create_screen(cols=4, lines=4, scrollback=4)
        s.draw(text)
        s.cursor.x, s.cursor.y = 1, 1
        self.ae(ac(), 'b')
        s.resize(s.lines, s.columns * 2)
        self.ae(ac(), 'b')
        self.ae(str(s.historybuf), '11112222')
        self.ae(at(), text + '\n\n')
        self.ae((s.cursor.x, s.cursor.y), (1, 0))

        # test that trailing blank line is preserved on resize
        s = self.create_screen(cols=5, lines=5, scrollback=15)
        for i in range(3):
            s.draw(f'oo{i}'), s.index(), s.carriage_return()
        s.draw('$ pp'), s.index(), s.carriage_return()
        s.resize(s.lines, 2)
        self.assertFalse(str(s.line(s.cursor.y)))
        self.assertFalse(s.cursor.x)
        # test that only happens when last line is not continued
        s = self.create_screen(cols=5, lines=5, scrollback=15)
        for i in range(3):
            s.draw(f'oo{i}'), s.index(), s.carriage_return()
        s.draw('p' * (s.columns + 2)), s.carriage_return()
        s.resize(s.lines, 2)
        self.assertTrue(str(s.line(s.cursor.y)))
        s = self.create_screen(cols=5, lines=5, scrollback=15)
        s.draw('12345'), s.carriage_return(), s.index()
        s.resize(s.lines, s.columns - 1)
        self.ae(('1234', '5', ''), tuple(str(s.line(i)) for i in range(s.cursor.y+1)))

        s = self.create_screen(scrollback=6)
        s.draw(''.join([str(i) * s.columns for i in range(s.lines)]))
        s.resize(3, 10)
        self.ae(str(s.line(0)), '0'*5 + '1'*5)
        self.ae(str(s.line(1)), '2'*5 + '3'*5)
        self.ae(str(s.line(2)), '4'*5)
        s.resize(5, 1)
        self.ae(str(s.line(0)), '4')
        self.ae(str(s.historybuf), '3\n3\n3\n3\n3\n2')
        s = self.create_screen(scrollback=20)
        s.draw(''.join(str(i) * s.columns for i in range(s.lines*2)))
        self.ae(str(s.linebuf), '55555\n66666\n77777\n88888\n99999')
        before = at()
        s.resize(5, 2)
        self.ae(before, at())
        self.ae(str(s.linebuf), '88\n88\n89\n99\n99')
        s = self.create_screen()
        s.draw('a' * s.columns)
        s.linefeed(), s.carriage_return()
        s.draw('bb')
        s.resize(s.lines, s.columns - 2)
        self.ae(str(s.linebuf), 'aaa\naa\nbb\n\n')
        s.cursor.y = s.cursor.x = 0
        s.draw('x' * len(str(s.line(0))))
        s.linefeed(), s.carriage_return()
        s.draw('x' * len(str(s.line(1))))
        s.resize(s.lines, s.columns + 4)
        self.ae(str(s.linebuf), 'xxx\nxx\nbb\n\n')
        s = self.create_screen()
        c = s.callbacks
        parse_bytes(s, b'\x1b[?2048$p')  # ]
        self.ae(c.wtcbuf, b'\x1b[?2048;2$y')  # ]
        c.clear()
        parse_bytes(s, b'\x1b[?2048h\x1b[?2048$p')  # ]]
        self.ae(c.wtcbuf, b'\x1b[?2048;1$y')  # ]
        self.ae(c.num_of_resize_events, 1)
        parse_bytes(s, b'\x1b[?2048h')  # ]
        self.ae(c.num_of_resize_events, 2)

    def test_da1(self):
        s = self.create_screen()
        parse_bytes(s, b'\x1b[c\x1b[0c')  # ]]
        self.ae(s.callbacks.da1, ['?62;52;c', '?62;52;c'])  # ]]
        s.callbacks.clear()
        self.create_screen(options={'clipboard_control': 'read-clipboard'})
        parse_bytes(s, b'\x1b[c')  # ]]
        self.ae(s.callbacks.da1, ['?62;c'])  # ]]

    def test_cursor_after_resize(self):

        def draw(text, end_line=True):
            s.draw(text)
            if end_line:
                s.linefeed(), s.carriage_return()

        s = self.create_screen()
        draw('123'), draw('123')
        y_before = s.cursor.y
        s.resize(s.lines, s.columns-1)
        self.ae(y_before, s.cursor.y)

        s = self.create_screen(cols=5, lines=8)
        draw('one')
        draw('two three four five |||', end_line=False)
        s.resize(s.lines + 2, s.columns + 2)
        y = s.cursor.y
        self.assertIn('|', str(s.line(y)))

        s = self.create_screen()
        draw('a')
        x_before = s.cursor.x
        s.resize(s.lines - 1, s.columns)
        self.ae(x_before, s.cursor.x)

        s = self.create_screen()
        s.draw('abc')
        b = s.cursor.x
        s.resize(7, s.columns)
        self.assertEqual(s.cursor.x, b)
        s.cursor.x = 0
        s.resize(5, s.columns)
        self.assertEqual(s.cursor.x, 0)

    def test_scrollback_fill_after_resize(self):
        def prepare_screen(content=()):
            ans = self.create_screen(options={'scrollback_fill_enlarged_window': True})
            for line in content:
                ans.draw(line)
                ans.linefeed()
                ans.carriage_return()
            return ans

        def assert_lines(*lines):
            return self.ae(lines, tuple(str(s.line(i)) for i in range(s.lines)))

        # test the reverse scroll function
        s = prepare_screen(map(str, range(6)))
        assert_lines('2', '3', '4', '5', '')
        s.reverse_scroll(2, True)
        assert_lines('0', '1', '2', '3', '4')

        # Height increased, width unchanged → pull down lines to fill new space at the top
        s = prepare_screen(map(str, range(6)))
        assert_lines('2', '3', '4', '5', '')
        dist_from_bottom = s.lines - s.cursor.y
        s.resize(7, s.columns)
        assert_lines('0', '1', '2', '3', '4', '5', '')
        self.ae(dist_from_bottom, s.lines - s.cursor.y)

        # Height increased, width increased → rewrap, pull down
        s = prepare_screen(['0', '1', '2', '3' * 15])
        assert_lines('2', '33333', '33333', '33333', '')
        s.resize(7, 12)
        assert_lines('0', '1', '2', '333333333333', '333', '', '')

        # Height increased, width decreased → rewrap, pull down if possible
        s = prepare_screen(['0', '1', '2', '3' * 5])
        assert_lines('0', '1', '2', '33333', '')
        s.resize(6, 4)
        assert_lines('0', '1', '2', '3333', '3', '')

        # Height unchanged, width increased → rewrap, pull down if possible
        s = prepare_screen(['0', '1', '2', '3' * 15])
        assert_lines('2', '33333', '33333', '33333', '')
        s.resize(s.lines, 12)
        assert_lines('1', '2', '333333333333', '333', '')

        # Height decreased, width increased → rewrap, pull down if possible
        s = prepare_screen(['0', '1', '2', '3' * 15])
        assert_lines('2', '33333', '33333', '33333', '')
        s.resize(4, 12)
        assert_lines('2', '333333333333', '333', '')

        # Height increased with large continued text
        s = self.create_screen(options={'scrollback_fill_enlarged_window': True})
        s.draw(('x' * (s.columns * s.lines * 2)) + 'abcde')
        s.carriage_return(), s.linefeed()
        s.draw('>')
        assert_lines('xxxxx', 'xxxxx', 'xxxxx', 'abcde', '>')
        s.resize(s.lines + 2, s.columns)
        assert_lines('xxxxx', 'xxxxx', 'xxxxx', 'xxxxx', 'xxxxx', 'abcde', '>')

    def test_tab_stops(self):
        # Taken from vttest/main.c
        s = self.create_screen(cols=80, lines=2)
        s.cursor_position(1, 1)
        s.clear_tab_stop(3)
        for col in range(1, s.columns - 1, 3):
            s.cursor_forward(3)
            s.set_tab_stop()
        s.cursor_position(1, 4)
        for col in range(4, s.columns - 1, 6):
            s.clear_tab_stop(0)
            s.cursor_forward(6)
        s.cursor_position(1, 7)
        s.clear_tab_stop(2)
        s.cursor_position(1, 1)
        for col in range(1, s.columns - 1, 6):
            s.tab()
            s.draw('*')
        s.cursor_position(2, 2)
        self.ae(str(s.line(0)), '\t*'*13)
        s = self.create_screen(cols=4, lines=2)
        s.draw('aaaX\tbbbb')
        self.ae(str(s.line(0)) + str(s.line(1)), 'aaaXbbbb')
        # DECST8C: reset tab stops to every 8 columns
        s = self.create_screen(cols=20, lines=2)
        s.clear_tab_stop(3)
        s.reset_tab_stops()
        s.cursor_position(1, 1)
        s.tab()
        self.ae(s.cursor.x, 8)
        s.tab()
        self.ae(s.cursor.x, 16)
        # Verify the DECST8C escape sequence
        s = self.create_screen(cols=20, lines=2)
        s.clear_tab_stop(3)
        parse_bytes(s, b'\x1b[?5W')
        s.cursor_position(1, 1)
        s.tab()
        self.ae(s.cursor.x, 8)

        # Custom tab stops survive a window resize
        s = self.create_screen(cols=20, lines=2)
        s.clear_tab_stop(3)  # clear all
        s.cursor_position(1, 5)
        s.set_tab_stop()  # stop at column index 4
        s.cursor_position(1, 13)
        s.set_tab_stop()  # stop at column index 12
        # Grow: existing stops preserved, new columns get default every-8 stops
        s.resize(s.lines, 30)
        s.cursor_position(1, 1)
        s.tab()
        self.ae(s.cursor.x, 4)
        s.tab()
        self.ae(s.cursor.x, 12)
        s.tab()
        self.ae(s.cursor.x, 24)  # default stop in newly added columns
        # Shrink: stops within new width are preserved
        s.resize(s.lines, 15)
        s.cursor_position(1, 1)
        s.tab()
        self.ae(s.cursor.x, 4)
        s.tab()
        self.ae(s.cursor.x, 12)
        # Resize on alt screen also preserves alt-screen tab stops
        s = self.create_screen(cols=20, lines=2)
        parse_bytes(s, b'\x1b[?1049h')  # switch to alt screen ]
        s.clear_tab_stop(3)
        s.cursor_position(1, 5)
        s.set_tab_stop()
        s.resize(s.lines, 30)
        s.cursor_position(1, 1)
        s.tab()
        self.ae(s.cursor.x, 4)

    def test_backspace(self):
        s = self.create_screen()
        q = 'a'*s.columns
        def backspace(use_bs=True):
            if use_bs:  # this is how the kernel implements backspace
                s.draw('\x08 \x08')
            else:
                s.cursor_move(1)
                s.draw(' ')
                s.cursor_move(1)
        for use_bs in (True, False):
            s.reset()
            s.draw(q)
            s.draw('b')
            backspace(use_bs)
            self.ae(str(s.line(0)), q)
            self.ae(str(s.line(1)), ' ')
            self.ae(s.cursor.x, 0)
            backspace(use_bs)
            self.ae(str(s.line(0)), q[:-1] + ' ')
            self.ae(str(s.line(1)), ' ')
        # Test that CUB does not move cursor onto previous line
        s.reset()
        s.draw('a'*s.columns + 'b')
        self.ae((s.cursor.x, s.cursor.y), (1, 1))
        parse_bytes(s, b'\x1b[100D')
        self.ae((s.cursor.x, s.cursor.y), (0, 1))

    def test_margins(self):
        # Taken from vttest/main.c
        s = self.create_screen(cols=80, lines=24)

        def nl():
            s.carriage_return(), s.linefeed()

        for deccolm in (False, True):
            if deccolm:
                s.resize(24, 132)
                s.set_mode(DECCOLM)
            else:
                s.reset_mode(DECCOLM)
            region = s.lines - 6
            s.set_margins(3, region + 3)
            s.set_mode(DECOM)
            for i in range(26):
                ch = chr(ord('A') + i)
                which = i % 4
                if which == 0:
                    s.cursor_position(region + 1, 1), s.draw(ch)
                    s.cursor_position(region + 1, s.columns), s.draw(ch.lower())
                    nl()
                elif which == 1:
                    # Simple wrapping
                    s.cursor_position(region, s.columns), s.draw(chr(ord('A') + i - 1).lower() + ch)
                    # Backspace at right margin
                    s.cursor_position(region + 1, s.columns), s.draw(ch), s.backspace(), s.draw(ch.lower())
                    nl()
                elif which == 2:
                    # Tab to right margin
                    s.cursor_position(region + 1, s.columns), s.draw(ch), s.backspace(), s.backspace(), s.tab(), s.tab(), s.draw(ch.lower())
                    s.cursor_position(region + 1, 2), s.backspace(), s.draw(ch), nl()
                else:
                    s.cursor_position(region + 1, 1), nl()
                    s.cursor_position(region, 1), s.draw(ch)
                    s.cursor_position(region, s.columns), s.draw(ch.lower())
            for ln in range(2, region + 2):
                c = chr(ord('I') + ln - 2)
                before = '\t' if ln % 4 == 0 else ' '
                self.ae(c + ' ' * (s.columns - 3) + before + c.lower(), str(s.line(ln)))
            s.reset_mode(DECOM)
        # Test that moving cursor outside the margins works as expected
        s = self.create_screen(10, 10)
        s.set_margins(4, 6)
        s.cursor_position(0, 0)
        self.ae(s.cursor.y, 0)
        nl()
        self.ae(s.cursor.y, 1)
        s.cursor.y = s.lines - 1
        self.ae(s.cursor.y, 9)
        s.reverse_index()
        self.ae(s.cursor.y, 8)

    def test_sgr(self):
        s = self.create_screen()
        s.select_graphic_rendition(0, 1, 5, 37, 42)
        s.draw('a')
        c = s.line(0).cursor_from(0)
        self.assertTrue(c.bold)
        self.assertTrue(c.blink)
        self.ae(c.bg, (2 << 8) | 1)
        self.ae('\x1b[22;1;5;37;42ma', s.line(0).as_ansi())
        s.cursor_position(2, 1)
        s.select_graphic_rendition(0, 35)
        s.draw('b')
        c = s.line(1).cursor_from(0)
        self.ae(c.fg, (5 << 8) | 1)
        self.ae(c.bg, 0)
        s.cursor_position(2, 2)
        s.select_graphic_rendition(38, 2, 99, 1, 2, 3)
        s.draw('c')
        c = s.line(1).cursor_from(1)
        self.ae(c.fg, (1 << 24) | (2 << 16) | (3 << 8) | 2)

    def test_cursor_hidden(self):
        s = self.create_screen()
        s.toggle_alt_screen()
        s.cursor_visible = False
        s.toggle_alt_screen()
        self.assertFalse(s.cursor_visible)

    def test_dirty_lines(self):
        s = self.create_screen()
        self.assertFalse(s.linebuf.dirty_lines())
        s.draw('a' * (s.columns * 2))
        self.ae(s.linebuf.dirty_lines(), [0, 1])
        self.assertFalse(s.historybuf.dirty_lines())
        while not s.historybuf.count:
            s.draw('a' * (s.columns * 2))
        self.ae(s.historybuf.dirty_lines(), list(range(s.historybuf.count)))
        self.ae(s.linebuf.dirty_lines(), list(range(s.lines)))
        s.cursor.x, s.cursor.y = 0, 1
        s.insert_lines(2)
        self.ae(s.linebuf.dirty_lines(), [0, 3, 4])
        s.draw('a' * (s.columns * s.lines))
        self.ae(s.linebuf.dirty_lines(), list(range(s.lines)))
        s.cursor.x, s.cursor.y = 0, 1
        s.delete_lines(2)
        self.ae(s.linebuf.dirty_lines(), [0, 1, 2])

        s = self.create_screen()
        self.assertFalse(s.linebuf.dirty_lines())
        s.erase_in_line(0, False)
        self.ae(s.linebuf.dirty_lines(), [0])
        s.index(), s.index()
        s.erase_in_display(0, False)
        self.ae(s.linebuf.dirty_lines(), [0, 2, 3, 4])

        s = self.create_screen()
        self.assertFalse(s.linebuf.dirty_lines())
        s.insert_characters(2)
        self.ae(s.linebuf.dirty_lines(), [0])
        s.cursor.y = 1
        s.delete_characters(2)
        self.ae(s.linebuf.dirty_lines(), [0, 1])
        s.cursor.y = 2
        s.erase_characters(2)
        self.ae(s.linebuf.dirty_lines(), [0, 1, 2])

    def test_selection_as_text(self):
        s = self.create_screen()
        for i in range(2 * s.lines):
            if i != 0:
                s.carriage_return(), s.linefeed()
            s.draw(str(i) * s.columns)
        s.start_selection(0, 0)
        s.update_selection(4, 4)

        def ts(*args):
            return ''.join(s.text_for_selection(*args))

        expected = ''.join(('55555', '\n66666', '\n77777', '\n88888', '\n99999'))
        self.ae(ts(), expected)
        self.ae(ts(True), expected)
        s.scroll(2, True)
        self.ae(ts(), expected)
        self.ae(ts(True), expected)
        s.reset()
        s.draw('ab   cd')
        s.start_selection(0, 0)
        s.update_selection(1, 3)
        self.ae(ts(), ''.join(('ab   ', 'cd')))
        # soft-wrapped lines preserve trailing spaces (issue #9834)
        self.ae(ts(False, True), 'ab   cd')
        s.reset()
        s.draw('ab        cd')
        s.start_selection(0, 0)
        s.update_selection(3, 4)
        self.ae(s.text_for_selection(), ('ab   ', '     ', 'cd'))
        # soft-wrapped lines preserve trailing spaces (issue #9834)
        self.ae(s.text_for_selection(False, True), ('ab   ', '     ', 'cd'))
        s.reset()
        s.draw('a')
        s.select_graphic_rendition(32)
        s.draw('b')
        s.select_graphic_rendition(39)
        s.draw('c  xy')
        s.start_selection(0, 0)
        s.update_selection(1, 3)
        self.ae(s.text_for_selection(), ('abc  ', 'xy'))
        self.ae(s.text_for_selection(True), ('a\x1b[32mb\x1b[39mc  ', 'xy', '\x1b[m'))
        # soft-wrapped lines preserve trailing spaces in ANSI mode too (issue #9834)
        self.ae(s.text_for_selection(True, True), ('a\x1b[32mb\x1b[39mc  ', 'xy', '\x1b[m'))
        # ]]]]]]]]]]]]]]]]]]]]
        s.reset()
        s.draw('a'), s.carriage_return(), s.linefeed(), s.linefeed(), s.draw('b')
        s.start_selection(0, 0)
        s.update_selection(4, 4)
        self.ae(''.join(s.text_for_selection()), 'a\n\nb')
        self.ae(''.join(s.text_for_selection(True)), 'a\n\nb')
        # Trailing spaces on soft-wrapped lines must be preserved when strip_trailing_whitespace
        # is set: a space at the line-wrap boundary is word-separator content, not padding
        # (regression test for issue #9834)
        s.reset()
        s.draw('1234 5')  # "1234 " soft-wraps at col 4; space must survive stripping
        s.start_selection(0, 0)
        s.update_selection(4, 4)
        self.ae(s.text_for_selection(), ('1234 ', '5'))
        self.ae(s.text_for_selection(False, True), ('1234 ', '5'))
        self.ae(s.text_for_selection(True, True), ('1234 ', '5', ''))

    def test_apply_selection_with_paused_rendering_and_scrollback(self):
        # Regression test: in 0.46.2 the paused-rendering branch of
        # apply_selection passed the (possibly negative) loop variable y
        # directly to linebuf_init_line, which interprets it as an unsigned
        # index_type and reads ~4GB out of bounds in line_attrs[idx]. The fix
        # translates to paused_y = y + scrolled_by and guards paused_y < 0.
        # Real-world trigger: a TUI sending DCS =1s (DEC synchronized output)
        # while the user has scrolled back and has an active scrollback
        # selection.
        s = self.create_screen(cols=10, lines=3, scrollback=50)
        for i in range(40):
            s.draw(f"row{i:03d}")
            s.carriage_return()
            s.linefeed()
        s.scroll(20, True)
        self.assertGreater(s.scrolled_by, 0)
        # Selection that crosses the top of the visible area into scrollback,
        # so the inner loop iterates with negative y.
        s.start_selection(0, 0)
        s.update_selection(2, 1)
        self.assertTrue(s.has_selection())
        self.assertTrue(s.pause_rendering(True, 5000))
        # Must not crash and must return the visible-area buffer.
        result = s.current_selections()
        self.ae(len(result), s.lines * s.columns)
        # The visible portion of the selection must have at least one byte
        # marked (set_mask = 1 for plain selections).
        self.assertIn(1, result)

    def test_soft_hyphen(self):
        s = self.create_screen()
        s.draw('a\u00adb')
        self.ae(s.cursor.x, 2)
        s.start_selection(0, 0)
        s.update_selection(2, 0)
        self.ae(s.text_for_selection(), ('a\u00adb',))

    def test_variation_selectors(self):
        s = self.create_screen(cols=3)
        q = '*\ufe0f'
        s.draw(q*(s.columns+1))
        self.ae(str(s.line(0)), q*(s.columns//2))
        s = self.create_screen(cols=8)
        def widths(text, *widths):
            s.reset()
            s.draw(text)
            def w(x):
                c = s.cpu_cells(0, x)
                return (c['mcd'] or {'width': 1})['width']
            actual = tuple(w(x) for x in range(len(widths)))
            self.ae(widths, actual)
        widths('\u4e00\u4e00\u26ab\ufe0e', 2, 2, 2, 2, 1)

        s = self.create_screen()
        def tt(text_to_draw):
            s.reset()
            s.draw(text_to_draw)
            self.ae(str(s.line(0)), text_to_draw)
        tt('abc\U0001f44d\ufe0ed')

        def t(*a):
            s.reset()
            for i in range(0, len(a), 2):
                char, x = a[i], a[i+1]
                s.draw(char)
                self.ae(s.cursor.x, x, f'after char: {char!r}')
        # already wide + VS15
        t('\U0001f610', 2, '\ufe0e', 1, '\ufe0e', 1)
        t('\U0001f610\ufe0e', 1, '\ufe0e', 1)
        # narrow + VS16
        t('\u25b6', 1, '\ufe0f', 2)
        t('\u25b6\ufe0f', 2)
        # wide + VS16
        t('\u26d4\ufe0f', 2, '\ufe0f', 2)
        t('\u26d4', 2, '\ufe0f', 2)
        # narrow + VS15
        t('\u25b6', 1, '\ufe0e', 1)
        t('\u25b6\ufe0e', 1)
        # narrow + VS16 + VS15
        t('\u25b6', 1, '\ufe0f', 2, '\ufe0e', 2)
        # wide + VS15 + VS16
        t('\U0001f610', 2, '\ufe0e', 1, '\ufe0f', 1)
        # large numbers of combining chars
        s.reset()
        s.draw("\N{HEAVY EXCLAMATION MARK SYMBOL}" + 4500 * "\N{VARIATION SELECTOR-16}")

    def test_writing_with_cursor_on_trailer_of_wide_character(self):
        s = self.create_screen()
        def r(x, pos, expected):
            s.reset()
            s.draw('😸')
            self.ae(s.cursor.x, 2)
            s.cursor.x = 1
            s.draw(x)
            self.ae(s.cursor.x, pos)
            self.ae(str(s.line(0)), expected)

        r('a', 2, ' a')
        r('😸', 3, ' 😸')
        r('\u0304', 1, '😸\u0304')
        r('\r', 0, '😸')


    def test_serialize(self):
        from kitty.window import as_text
        sgr0 = '\x1b[m'  # ]
        s = self.create_screen()
        parse_bytes(s, b'\x1b[1;91m')  # ]
        s.draw('X')
        parse_bytes(s, b'\x1b[0m\x1b[2m')  # ]]
        s.draw('Y')
        self.ae(as_text(s, True), f'{sgr0}\x1b[22;1;91mX\x1b[22;2;39mY\n\n\n\n')  # ]]

        s.reset()
        s.draw('ab' * s.columns)
        s.carriage_return(), s.linefeed()
        s.draw('c')

        self.ae(as_text(s), 'ababababab\nc\n\n')
        self.ae(as_text(s, True), f'{sgr0}ababa{sgr0}babab\n{sgr0}c\n\n')

        s = self.create_screen(cols=2, lines=2, scrollback=2)
        for i in range(1, 7):
            s.select_graphic_rendition(30 + i)
            s.draw(f'{i}' * s.columns)
        self.ae(as_text(s, True, True), f'{sgr0}\x1b[31m11{sgr0}\x1b[32m22{sgr0}\x1b[33m33{sgr0}\x1b[34m44{sgr0}{sgr0}\x1b[35m55{sgr0}\x1b[36m66')
        # ]]]]]]]]]]]]]]]]]]]]]

        def hl(url='', id=''):
            return '\x1b]8;{};{}\x1b\\'.format(f'id={id}' if id else '', url or '')

        def set_link(url=None, id=None):
            parse_bytes(s, hl(url, id).encode('utf-8'))

        s = self.create_screen()
        set_link('moo')
        s.draw('X')
        set_link()
        self.ae(as_text(s, True), f'{sgr0}{hl("moo")}X{hl()}\n\n\n\n')
        s.reset()
        set_link('moo')
        s.draw('X'*s.columns)
        set_link()
        self.ae(as_text(s, True), f'{sgr0}{hl("moo")}XXXXX\n{sgr0}{hl()}\n\n\n')

        s.reset()
        s.draw('a')
        set_link('moo', 'foo')
        s.draw('bcdef')
        self.ae(as_text(s, True), f'{sgr0}a{hl("moo", "foo")}bcde{sgr0}f{hl()}\n\n\n')
        set_link()
        s.draw('gh')
        self.ae(as_text(s, True), f'{sgr0}a{hl("moo", "foo")}bcde{sgr0}f{hl()}gh\n\n\n')
        s.reset()
        s.draw('a')
        set_link('moo')
        s.draw('bcdef')
        self.ae(as_text(s, True), f'{sgr0}a{hl("moo")}bcde{sgr0}f{hl()}\n\n\n')

    def test_wrapping_serialization(self):
        from kitty.window import as_text
        s = self.create_screen(cols=2, lines=2, scrollback=2, options={'scrollback_pager_history_size': 128})
        s.draw('ū̀abbccddeefū̀')
        self.ae(as_text(s, add_history=True), 'ū̀abbccddeefū̀')
        self.assertNotIn('\n', as_text(s, add_history=True, as_ansi=True))

        s = self.create_screen(cols=2, lines=2, scrollback=2, options={'scrollback_pager_history_size': 128})
        s.draw('1'), s.carriage_return(), s.linefeed()
        s.draw('2'), s.carriage_return(), s.linefeed()
        s.draw('3'), s.carriage_return(), s.linefeed()
        s.draw('4'), s.carriage_return(), s.linefeed()
        s.draw('5'), s.carriage_return(), s.linefeed()
        s.draw('6'), s.carriage_return(), s.linefeed()
        s.draw('7')
        self.ae(as_text(s, add_history=True), '1\n2\n3\n4\n5\n6\n7')

        s = self.create_screen(cols=2, lines=2, scrollback=2, options={'scrollback_pager_history_size': 128})
        s.draw('aabb')
        s.cursor.y = 0
        s.carriage_return(), s.linefeed()
        self.ae(as_text(s, add_history=True), 'aabb')

        s = self.create_screen(cols=2, lines=2, scrollback=2, options={'scrollback_pager_history_size': 128})
        s.draw('a'), s.carriage_return(), s.linefeed()
        s.cursor.y = 0
        s.draw('aabb')
        self.ae(as_text(s), 'aabb')
        s = self.create_screen(cols=2, lines=2, scrollback=2, options={'scrollback_pager_history_size': 128})
        s.draw('a😀')
        self.ae(as_text(s), 'a😀')

    def test_pagerhist(self):
        hsz = 8
        s = self.create_screen(cols=2, lines=2, scrollback=2, options={'scrollback_pager_history_size': hsz})

        def contents():
            return s.historybuf.pagerhist_as_text()

        def line(i):
            q.append('\x1b[m' + f'{i}' * s.columns + '\r')

        def w(x):
            s.historybuf.pagerhist_write(x)

        def test():
            expected = ''.join(q)
            maxlen = hsz
            extra = len(expected) - maxlen
            if extra > 0:
                expected = expected[extra:]
            got = contents()
            self.ae(got, expected)

        q = []
        for i in range(4):
            s.draw(f'{i}' * s.columns)
        self.ae(contents(), '')
        s.draw('4' * s.columns), line(0), test()
        s.draw('5' * s.columns), line(1), test()
        s.draw('6' * s.columns), line(2), test()
        s.draw('7' * s.columns), line(3), test()
        s.draw('8' * s.columns), line(4), test()
        s.draw('9' * s.columns), line(5), test()

        s = self.create_screen(options={'scrollback_pager_history_size': 2048})
        text = '\x1b[msoft\r\x1b[mbreak\nnext😼cat'
        w(text)
        self.ae(contents(), text)
        s.historybuf.pagerhist_rewrap(2)
        self.ae(contents(), '\x1b[mso\rft\x1b[m\rbr\rea\rk\nne\rxt\r😼\rca\rt')

        s = self.create_screen(options={'scrollback_pager_history_size': 8})
        w('😼')
        self.ae(contents(), '😼')
        w('abcd')
        self.ae(contents(), '😼abcd')
        w('e')
        self.ae(contents(), 'abcde')

    def test_user_marking(self):

        def cells(*a, y=0, mark=3):
            return [(x, y, mark) for x in a]

        s = self.create_screen()
        s.draw('abaa')
        s.carriage_return(), s.linefeed()
        s.draw('xyxyx')
        s.set_marker(marker_from_regex('a', 3))
        self.ae(s.marked_cells(), cells(0, 2, 3))
        s.set_marker()
        self.ae(s.marked_cells(), [])

        def mark_x(text):
            col = 0
            for i, c in enumerate(text):
                if c == 'x':
                    col += 1
                    yield i, i, col

        s.set_marker(marker_from_function(mark_x))
        self.ae(s.marked_cells(), [(0, 1, 1), (2, 1, 2), (4, 1, 3)])
        s = self.create_screen(lines=5, scrollback=10)
        for i in range(15):
            s.draw(str(i))
            if i != 14:
                s.carriage_return(), s.linefeed()
        s.set_marker(marker_from_regex(r'\d+', 3))
        for i in range(10):
            self.assertTrue(s.scroll_to_next_mark())
            self.ae(s.scrolled_by, i + 1)
        self.ae(s.scrolled_by, 10)
        for i in range(10):
            self.assertTrue(s.scroll_to_next_mark(0, False))
            self.ae(s.scrolled_by, 10 - i - 1)
        self.ae(s.scrolled_by, 0)

        s = self.create_screen()
        s.draw('🐈ab')
        s.set_marker(marker_from_regex('🐈', 3))
        self.ae(s.marked_cells(), cells(0, 1))
        s.set_marker(marker_from_regex('🐈a', 3))
        self.ae(s.marked_cells(), cells(0, 1, 2))
        s.set_marker(marker_from_regex('a', 3))
        self.ae(s.marked_cells(), cells(2))
        s = self.create_screen(cols=20)
        s.tab()
        s.draw('ab')
        s.set_marker(marker_from_regex('a', 3))
        self.ae(s.marked_cells(), cells(8))
        s.set_marker(marker_from_regex('\t', 3))
        self.ae(s.marked_cells(), cells(*range(8)))
        s = self.create_screen()
        s.cursor.x = 2
        s.draw('x')
        s.cursor.x += 1
        s.draw('x')
        s.set_marker(marker_from_function(mark_x))
        self.ae(s.marked_cells(), [(2, 0, 1), (4, 0, 2)])
        # Test CJK/wide characters not at position 0 (issue #9705)
        s = self.create_screen(cols=20)
        s.draw('テスト世界')
        s.set_marker(marker_from_regex('テ', 3))
        self.ae(s.marked_cells(), cells(0, 1))
        s.set_marker(marker_from_regex('世', 3))
        self.ae(s.marked_cells(), cells(6, 7))
        s.set_marker(marker_from_text('世界', 3))
        self.ae(s.marked_cells(), cells(6, 7, 8, 9))
        s = self.create_screen(cols=20)
        s.draw('ABテCD世EF')
        s.set_marker(marker_from_regex('テ', 3))
        self.ae(s.marked_cells(), cells(2, 3))
        s.set_marker(marker_from_regex('世', 3))
        self.ae(s.marked_cells(), cells(6, 7))

    def test_hyperlinks(self):
        s = self.create_screen()
        self.ae(s.line(0).hyperlink_ids(), tuple(0 for x in range(s.columns)))

        def set_link(url=None, id=None):
            parse_bytes(s, '\x1b]8;id={};{}\x1b\\'.format(id or '', url or '').encode('utf-8'))

        set_link('wide-chars', 'XX')
        self.ae(s.line(0).hyperlink_ids(), tuple(0 for x in range(s.columns)))
        s.draw('状')
        self.ae(s.line(0).hyperlink_ids(), (1, 1) + tuple(0 for x in range(s.columns - 2)))
        set_link()

        s = self.create_screen()
        set_link('url-a', 'a')
        self.ae(s.line(0).hyperlink_ids(), tuple(0 for x in range(s.columns)))
        s.draw('a')
        self.ae(s.line(0).hyperlink_ids(), (1,) + tuple(0 for x in range(s.columns - 1)))
        s.draw('bc')
        self.ae(s.line(0).hyperlink_ids(), (1, 1, 1, 0, 0))
        set_link()
        s.draw('d')
        self.ae(s.line(0).hyperlink_ids(), (1, 1, 1, 0, 0))
        set_link('url-a', 'a')
        s.draw('efg')
        self.ae(s.line(0).hyperlink_ids(), (1, 1, 1, 0, 1))
        self.ae(s.line(1).hyperlink_ids(), (1, 1, 0, 0, 0))
        set_link('url-b')
        s.draw('hij')
        self.ae(s.line(1).hyperlink_ids(), (1, 1, 2, 2, 2))
        set_link()
        self.ae({('a:url-a', 1), (':url-b', 2)}, s.hyperlinks_as_set())
        s.garbage_collect_hyperlink_pool()
        self.ae({('a:url-a', 1), (':url-b', 2)}, s.hyperlinks_as_set())
        for i in range(s.lines + 2):
            s.linefeed()
        s.garbage_collect_hyperlink_pool()
        self.ae({('a:url-a', 1), (':url-b', 2)}, s.hyperlinks_as_set())
        for i in range(s.lines * 2):
            s.linefeed()
        s.garbage_collect_hyperlink_pool()
        self.assertFalse(s.hyperlinks_as_set())
        set_link('url-a', 'x')
        s.draw('a')
        set_link('url-a', 'y')
        s.draw('a')
        set_link()
        self.ae({('x:url-a', 1), ('y:url-a', 2)}, s.hyperlinks_as_set())

        s = self.create_screen()
        set_link('u' * 2048)
        s.draw('a')
        self.ae({(':' + 'u' * 2045, 1)}, s.hyperlinks_as_set())
        s = self.create_screen()
        set_link('u' * 2048, 'i' * 300)
        s.draw('a')
        self.ae({('i'*256 + ':' + 'u' * (2045 - 256), 1)}, s.hyperlinks_as_set())

        s = self.create_screen()
        set_link('1'), s.draw('1')
        set_link('2'), s.draw('2')
        set_link('3'), s.draw('3')
        s.cursor.x = 1
        set_link(), s.draw('X')
        self.ae(s.line(0).hyperlink_ids(), (1, 0, 3, 0, 0))
        self.ae({(':1', 1), (':2', 2), (':3', 3)}, s.hyperlinks_as_set())
        s.garbage_collect_hyperlink_pool()
        self.ae({(':1', 1), (':3', 2)}, s.hyperlinks_as_set())
        set_link('3'), s.draw('3')
        self.ae({(':1', 1), (':3', 2)}, s.hyperlinks_as_set())
        set_link('4'), s.draw('4')
        self.ae({(':1', 1), (':3', 2), (':4', 3)}, s.hyperlinks_as_set())

        s = self.create_screen()
        set_link('1'), s.draw('1')
        set_link('2'), s.draw('2')
        set_link('1'), s.draw('1')
        self.ae({(':2', 2), (':1', 1)}, s.hyperlinks_as_set())

        s = self.create_screen()
        set_link('1'), s.draw('12'), set_link(), s.draw('X'), set_link('1'), s.draw('3')
        s.linefeed(), s.carriage_return()
        s.draw('abc')
        s.linefeed(), s.carriage_return()
        set_link(), s.draw('Z ')
        set_link('1'), s.draw('xyz')
        s.linefeed(), s.carriage_return()
        set_link('2'), s.draw('Z Z')
        self.assertIsNone(s.current_url_text())
        self.assertIsNone(s.hyperlink_at(0, 4))
        self.assertIsNone(s.current_url_text())
        self.ae(s.hyperlink_at(0, 0), '1')
        self.ae(s.current_url_text(), '123abcxyz')
        self.ae('1', s.hyperlink_at(3, 2))
        self.ae(s.current_url_text(), '123abcxyz')
        self.ae('2', s.hyperlink_at(1, 3))
        self.ae(s.current_url_text(), 'Z Z')

    def test_text_cache_garbage_collection(self):
        # unique multi-codepoint cell texts, single width base + combining mark
        def unique_text(i):
            return chr(0x100 + i // 0x70) + chr(0x300 + i % 0x70)

        s = self.create_screen()
        base = s.text_cache_count()
        for i in range(10):
            s.draw(unique_text(i))
        self.ae(s.text_cache_count(), base + 10)
        before = tuple(str(s.line(y)) for y in range(s.lines))
        s.garbage_collect_text_cache()
        # all entries are still referenced by cells, so all survive
        self.ae(s.text_cache_count(), base + 10)
        self.ae(before, tuple(str(s.line(y)) for y in range(s.lines)))

        # scroll all multi-codepoint cells out of the screen and the history
        # buffer, then intern one more entry, which gets a high index
        for i in range(s.lines * 3):
            s.linefeed()
        s.carriage_return()
        s.draw(unique_text(10))
        s.garbage_collect_text_cache()
        # only the surviving entry remains and its index was remapped
        # without changing the cell's text
        self.ae(s.text_cache_count(), base + 1)
        self.ae(str(s.line(s.cursor.y)).rstrip(), unique_text(10))

        # the periodic GC keeps the cache bounded when unique cell texts
        # are continuously created and scrolled out, as in the DoS scenario
        s = self.create_screen()
        num = 3 * 8192 + 100
        for i in range(num):
            s.draw(unique_text(i))
        self.assertLess(s.text_cache_count(), 8192 + 2 * s.lines * s.columns)
        self.ae(str(s.line(s.cursor.y)).rstrip()[-2:], unique_text(num - 1))

    def test_bottom_margin(self):
        s = self.create_screen(cols=80, lines=6, scrollback=4)
        s.set_margins(0, 5)
        for i in range(8):
            s.draw(str(i))
            s.linefeed()
            s.carriage_return()

        self.ae(str(s.linebuf), '4\n5\n6\n7\n\n')
        self.ae(str(s.historybuf), '3\n2\n1\n0')

    def test_top_margin(self):
        s = self.create_screen(cols=80, lines=6, scrollback=4)
        s.set_margins(2, 6)
        for i in range(8):
            s.draw(str(i))
            s.linefeed()
            s.carriage_return()

        self.ae(str(s.linebuf), '0\n4\n5\n6\n7\n')
        self.ae(str(s.historybuf), '')

    def test_top_and_bottom_margin(self):
        s = self.create_screen(cols=80, lines=6, scrollback=4)
        s.set_margins(2, 5)
        for i in range(8):
            s.draw(str(i))
            s.linefeed()
            s.carriage_return()

        self.ae(str(s.linebuf), '0\n5\n6\n7\n\n')
        self.ae(str(s.historybuf), '')

    def test_osc_52(self):
        s = self.create_screen()
        c = s.callbacks

        def send(what: str):
            return parse_bytes(s, f'\033]52;p;{what}\a'.encode('ascii'))

        def t(q, *expected):
            c.clear()
            send(q)
            del q
            t.ex = list(expected)
            del expected
            try:
                self.ae(tuple(map(len, c.cc_buf)), tuple(map(len, t.ex)))
                self.ae(c.cc_buf, t.ex)
            finally:
                del t.ex

        t('XYZ', ('p;XYZ', False))
        t('a' * VT_PARSER_BUFFER_SIZE, ('p;' + 'a' * (VT_PARSER_BUFFER_SIZE - 8), True), (';' + 'a' * 8, False))
        t('', ('p;', False))
        t('!', ('p;!', False))

    def test_key_encoding_flags_stack(self):
        s = self.create_screen()
        c = s.callbacks

        def w(code, p1='', p2=''):
            p = f'{p1}'
            if p2:
                p += f';{p2}'
            return parse_bytes(s, f'\033[{code}{p}u'.encode('ascii'))

        def ac(flags):
            parse_bytes(s, b'\033[?u')
            self.ae(c.wtcbuf, f'\033[?{flags}u'.encode('ascii'))
            c.clear()

        ac(0)
        w('=', 0b1001)
        ac(0b1001)
        w('=', 0b0011, 2)
        ac(0b1011)
        w('=', 0b0110, 3)
        ac(0b1001)
        s.reset()
        ac(0)

        w('>', 0b0011)
        ac(0b0011)
        w('=', 0b1111)
        ac(0b1111)
        w('>', 0b10)
        ac(0b10)
        w('<')
        ac(0b1111)
        for i in range(10):
            w('<')
            ac(0)
        s.reset()

        for i in range(1, 16):
            w('>', i)
        ac(15)
        w('<'), ac(14), w('<'), ac(13)

    def test_color_stack(self):
        s = self.create_screen()
        c = s.callbacks

        def w(code):
            return parse_bytes(s, ('\033[' + code).encode('ascii'))

        def ac(idx, count):
            self.ae(c.wtcbuf, f'\033[{idx};{count}#Q'.encode('ascii'))
            c.clear()
        # ]]]]]]]]]]]]]]]]}}}}}}}}}}}}}}}}))))))))))))))))))))))

        w('#R')
        ac(0, 0)

        w('#P')
        w('#R')
        ac(0, 1)
        w('10#P')
        w('#R')
        ac(0, 1)
        w('#Q')
        w('#R')
        ac(0, 0)
        for i in range(20):
            w('#P')
        w('#R')
        ac(9, 10)

    def test_detect_url(self):
        detect_url(self)
        detect_url(self, scale=2)

    def test_prompt_marking(self):
        # ]]]]]]]]]]]]]]]]}}}}}}}}}}}}}}}}))))))))))))))))))))))

        def mark_prompt():
            parse_bytes(s, b'\033]133;A\007')

        def mark_output():
            parse_bytes(s, b'\033]133;C\007')

        def draw_prompt(x):
            mark_prompt(), s.draw(f'$ {x}'), s.carriage_return(), s.index()

        def draw_output(n, x='', m=True):
            if m:
                mark_output()
            for i in range(n):
                s.draw(f'{i}{x}'), s.index(), s.carriage_return()

        from kitty.window import as_text
        def at():
            return as_text(s, add_history=True)

        s = self.create_screen(cols=5, lines=5, scrollback=15)
        draw_output(3, 'oo')
        draw_prompt('pp')
        mark_output()
        s.toggle_alt_screen()
        s.resize(s.lines, 2)
        s.toggle_alt_screen()
        self.assertFalse(str(s.line(s.cursor.y)))

        s = self.create_screen(scrollback=10)
        draw_output(1, 'start')
        for i in range(4):
            draw_prompt(str(i) + 'ab')
            draw_output(1, 'ut')
        self.ae(s.scrolled_by, 0)
        self.ae(str(s.visual_line(0)), '$ 2ab')
        self.assertFalse(s.scroll_to_prompt(-3))
        self.assertTrue(s.scroll_to_prompt())
        self.ae(str(s.visual_line(0)), '$ 1ab')
        self.assertTrue(s.scroll_to_prompt())
        self.ae(str(s.visual_line(0)), '$ 0ab')
        self.assertFalse(s.scroll_to_prompt())
        self.assertFalse(s.scroll_to_prompt(4))
        self.assertTrue(s.scroll_to_prompt(1))
        self.ae(str(s.visual_line(0)), '$ 1ab')
        self.assertTrue(s.scroll_to_prompt(1))
        self.ae(str(s.visual_line(0)), '$ 2ab')
        self.assertFalse(s.scroll_to_prompt(1))
        # wrap prompts
        s.resize(s.lines + 1, s.columns - 2)
        self.ae(s.scrolled_by, 0)
        self.ae(str(s.visual_line(0)), 'ab')
        self.assertFalse(s.scroll_to_prompt(-4))
        self.assertTrue(s.scroll_to_prompt())
        self.ae(str(s.visual_line(0)), '$ 2')
        self.assertTrue(s.scroll_to_prompt())
        self.ae(str(s.visual_line(0)), '$ 1')
        self.assertTrue(s.scroll_to_prompt())
        self.ae(str(s.visual_line(0)), '$ 0')
        self.assertFalse(s.scroll_to_prompt())
        self.assertFalse(s.scroll_to_prompt(4))
        self.assertTrue(s.scroll_to_prompt(1))
        self.ae(str(s.visual_line(0)), '$ 1')
        self.assertTrue(s.scroll_to_prompt(1))
        self.ae(str(s.visual_line(0)), '$ 2')
        self.assertTrue(s.scroll_to_prompt(1))
        self.ae(str(s.visual_line(0)), 'ab')
        self.assertFalse(s.scroll_to_prompt(1))

        s = self.create_screen()
        mark_prompt(), s.draw('$ 0')
        s.carriage_return(), s.index()
        mark_prompt(), s.draw('$ 1')
        for i in range(s.lines):
            s.carriage_return(), s.index()
            s.draw(str(i))
        self.assertTrue(s.scroll_to_prompt())
        self.ae(str(s.visual_line(0)), '$ 1')

        def lco(as_ansi=False, which=0):
            a = []
            if s.cmd_output(which, a.append, as_ansi):
                pht = pagerhist(s, as_ansi=as_ansi, upto_output_start=True)
                if pht:
                    a.insert(0, pht)
            return ''.join(a)

        def fco():
            a = []
            s.cmd_output(1, a.append)
            return ''.join(a)

        def lvco():
            a = []
            s.cmd_output(2, a.append)
            return ''.join(a)

        s = self.create_screen(cols=5, lines=5, scrollback=15)
        draw_prompt('P' * s.columns)
        draw_output(s.lines + 1, 'a')  # ensure prompt is in scrollback
        draw_prompt('Q' * s.columns)
        draw_output(s.lines + 1, 'b')  # ensure prompt is in scrollback
        draw_prompt('R' * s.columns)
        self.ae(lco(), '0b\n1b\n2b\n3b\n4b\n5b')

        s = self.create_screen()
        s.draw('abcd'), s.index(), s.carriage_return()
        s.draw('12'), s.index(), s.carriage_return()
        self.ae(fco(), '')
        self.ae(lco(), 'abcd\n12\n')
        s = self.create_screen()
        mark_prompt(), s.draw('$ 0')
        s.carriage_return(), s.index()
        mark_output()
        s.draw('abcd'), s.index(), s.carriage_return()
        s.draw('12'), s.index(), s.carriage_return()
        mark_prompt(), s.draw('$ 1')
        self.ae(fco(), 'abcd\n12')
        self.ae(lco(), 'abcd\n12')
        self.ae(lco(as_ansi=True), '\x1b[m\x1b]133;C\x1b\\abcd\n\x1b[m12')  # ]]]

        s = self.create_screen(cols=5, lines=5, scrollback=15)
        draw_output(1, 'start', False)
        draw_prompt('0'), draw_output(3)
        draw_prompt('1')
        draw_prompt('2'), draw_output(2, 'x')

        # last cmd output
        # test: find upwards
        self.ae(s.scrolled_by, 0)
        self.ae(lco(), '0x\n1x\n')
        # get output after scroll up
        s.scroll_to_prompt()
        self.ae(s.scrolled_by, 4)
        self.ae(str(s.visual_line(0)), '$ 0')
        self.ae(lco(), '0x\n1x\n')

        # first cmd output on screen
        # test: find around
        self.ae(fco(), '0\n1\n2')
        s.scroll(2, False)
        self.ae(s.scrolled_by, 2)
        self.ae(str(s.visual_line(0)), '1')
        self.ae(fco(), '0x\n1x\n')
        # test: find downwards
        s.scroll(2, False)
        self.ae(str(s.visual_line(0)), '$ 1')
        self.ae(fco(), '0x\n1x\n')
        # test: obscure prompt in scrollback
        s.resize(3, 5)
        self.ae(str(s.visual_line(0)), '0x')
        # without succeeding prompt
        self.ae(fco(), '0x\n1x\n')
        s.resize(4, 5)
        draw_prompt('3')
        # with succeeding prompt
        self.ae(fco(), '0x\n1x')

        # resize
        # get last cmd output with continued output mark
        draw_output(1, 'long_line'), draw_output(2, 'l', False)
        s.resize(4, 5)
        s.scroll_to_prompt(-4)
        self.ae(str(s.visual_line(0)), '$ 0')
        self.ae(lco(), '0long_line\n0l\n1l\n')

        # last visited cmd output
        self.ae(lvco(), '0\n1\n2')
        s.scroll_to_prompt(1)
        self.ae(lvco(), '0x\n1x')
        # test: obscure prompt
        s.scroll(2, False)
        s.set_last_visited_prompt()
        self.ae(lvco(), '0x\n1x')
        # test: prompts without output
        s.scroll(s.scrolled_by, False)
        s.resize(5, s.columns + 5)
        draw_prompt('4')
        s.set_last_visited_prompt(2)
        self.ae(lvco(), '')
        draw_prompt('wrapcmd')
        self.ae(lvco(), '')
        draw_output(1, 'wrapout'), draw_output(1, 'y', False)
        s.set_last_visited_prompt(0)
        self.ae(lvco(), '0wrapout\n0y\n')
        # wrap long prompt with long output
        s.resize(5, s.columns - 5)
        # test: set last visited to previous empty prompt
        s.scroll_to_prompt(-2)
        self.ae(str(s.visual_line(0)), '$ 4')
        self.ae(lvco(), '0wrapout\n0y\n')
        draw_prompt('end')
        s.scroll_to_prompt(-1)
        self.ae(lvco(), '0wrapout\n0y')
        s.scroll_to_prompt(1)
        self.ae(lvco(), '0wrapout\n0y')
        # test: set last visited to continued line of long prompt
        s.set_last_visited_prompt(1)
        self.ae(lvco(), '0wrapout\n0y')
        # test: set last visited to continued line of output
        s.set_last_visited_prompt(3)
        self.ae(lvco(), '0wrapout\n0y')

        # test: losing markers past scrollback
        s = self.create_screen(lines=10, scrollback=0)
        draw_prompt('a' * (s.columns * 3))
        draw_output(1, 'v' * (s.columns * 2)), draw_output(1, 'w', False)
        draw_prompt('b')
        draw_output(1, 'x')
        # remove prompt start above, set last visited to within prompt
        s.clear_scrollback()
        s.set_last_visited_prompt(0)
        self.ae(lvco(), '0vvvvvvvvvv\n0w')
        # remove output start above, set last visited to within output
        draw_output(3, 'y', False), draw_output(1, 'z', False)
        s.clear_scrollback()
        s.set_last_visited_prompt(0)
        self.ae(lvco(), 'vvvvvv\n0w')
        draw_output(1, 'end', False)
        s.clear_scrollback()
        s.set_last_visited_prompt(0)
        self.ae(lvco(), 'v\n0w')
        # clear last visited line without setting new one
        draw_output(1, 'end', False)
        s.clear_scrollback()
        self.ae(lvco(), '')

        # test that post rewrap prompt lines have correct attributes
        s = self.create_screen(cols=5, lines=5, scrollback=15)
        draw_prompt('P' * (s.columns - 2))
        draw_output(s.lines + 1, 'a')  # ensure prompt is in scrollback
        draw_prompt('Q' * (s.columns - 2))
        self.ae(str(s.visual_line(0)), '3a')
        s.scroll_to_prompt()
        self.ae(str(s.visual_line(0)), '$ PPP')
        s.scroll_to_prompt(1)
        self.ae(str(s.visual_line(0)), '3a')
        s.resize(s.lines, s.columns - 2)
        s.scroll_to_prompt()
        self.ae(str(s.visual_line(0)), '$ P')

        # last command output without line break
        s = self.create_screen(cols=10, lines=3)
        draw_prompt('p1')
        mark_output(), s.draw('running')
        self.ae(lco(), 'running')
        s.index(), s.carriage_return()
        self.ae(lco(), 'running\n')

        # last command output from pager history
        s = self.create_screen()
        draw_prompt('p1')
        draw_output(30)
        self.ae(tuple(map(int, lco().split())), tuple(range(0, 30)))

        # last non empty command output
        s = self.create_screen()
        draw_prompt('a'), draw_output(2, 'a')
        draw_prompt('b'), mark_output()
        self.ae(lco(), '')
        self.ae(lco(which=3), '0a\n1a')
        s.draw('running'), s.index(),  s.carriage_return()
        self.ae(lco(which=3), 'running\n')
        s = self.create_screen()
        draw_prompt('p1')
        draw_output(30)
        self.ae(tuple(map(int, lco(which=3).split())), tuple(range(0, 30)))
        s = self.create_screen()
        draw_prompt('p1')
        draw_output(2, 'a')
        draw_prompt('p1')
        draw_prompt('p1')
        self.ae(lco(which=3), '0a\n1a')

        # erase last command
        s = self.create_screen(cols=10, lines=10, scrollback=15)
        s.draw('before\r\n')
        draw_prompt('p1'), draw_output(2), mark_prompt(), s.draw('partial')
        self.ae('before\n$ p1\n0\n1\npartial', at().rstrip())
        self.ae(lco(), '0\n1')
        x = s.cursor.x
        s.erase_last_command()
        self.ae('before\npartial', at().rstrip())
        self.ae((x, 1), (s.cursor.x, s.cursor.y))
        s.reset()
        s.draw('before\r\n')
        draw_prompt('p1'), draw_output(2), mark_prompt(), s.draw('partial')
        x = s.cursor.x
        s.erase_last_command()
        self.ae('before\npartial', at().rstrip())
        for scroll in (8, 9, 10):
            s.reset()
            s.draw('before'), s.carriage_return(), s.linefeed()
            draw_prompt('p1'), draw_output(scroll), mark_prompt(), s.draw('partial')
            s.erase_last_command()
            self.ae('before\npartial', at().rstrip())
        s.reset()
        draw_multicell(s, 'A', scale=2), s.draw('a'), draw_multicell(s, 'B', scale=2), s.draw('b\r\n')
        draw_prompt('p1'), draw_output(9), mark_prompt(), s.draw('partial')
        s.erase_last_command()
        self.ae(at().rstrip(), '  a  b\npartial')

        # the most recent command is erased even when it produced no output (an
        # empty Enter, a comment, cd, ...): such commands emit no OSC 133;C and
        # must not be skipped in favour of an older command-with-output.
        s = self.create_screen(cols=10, lines=12, scrollback=30)
        s.draw('before\r\n')
        draw_prompt('p1'), draw_output(2), draw_prompt('# note'), mark_prompt(), s.draw('partial')
        s.erase_last_command()
        self.ae('before\n$ p1\n0\n1\npartial', at().rstrip())  # the output-less command goes first
        s.erase_last_command()
        self.ae('before\npartial', at().rstrip())              # then the command with output
        # consecutive output-less commands are removed newest-first, one per call
        s.reset()
        s.draw('before\r\n')
        draw_prompt('p1'), draw_output(1), draw_prompt('e1'), draw_prompt('e2'), mark_prompt(), s.draw('partial')
        s.erase_last_command()
        self.ae('before\n$ p1\n0\n$ e1\npartial', at().rstrip())
        s.erase_last_command()
        self.ae('before\n$ p1\n0\npartial', at().rstrip())
        s.erase_last_command()
        self.ae('before\npartial', at().rstrip())
        # multi-line live prompt: the command region is erased with no residual
        s.reset()
        s.draw('before\r\n')
        draw_prompt('p1'), draw_output(9), mark_prompt(), s.draw('l1'), s.carriage_return(), s.index(), s.draw('partial')
        s.erase_last_command()
        self.ae('before\nl1\npartial', at().rstrip())

    def test_pointer_shapes(self):
        from kitty.window import set_pointer_shape
        s = self.create_screen()
        c = s.callbacks
        response = ''

        def cb(data):
            nonlocal response
            response = set_pointer_shape(s, str(data, 'utf-8'))
        c.set_pointer_shape = cb

        def send(a):
            nonlocal response
            response = ''
            parse_bytes(s, f'\x1b]22;{a}\x1b\\'.encode())
            return response

        self.ae(send('?__current__'), '0')
        self.ae(send('?__default__,__grabbed__,default,ne-resize,crosshair,XXX'), 'text,default,1,1,1,0')

        def t(q, e=None):
            self.ae(send(q), '')
            self.ae(send('?__current__'), e)

        t('default', 'default')
        s.reset()
        self.ae(send('?__current__'), '0')
        t('=crosshair', 'crosshair')
        t('<', '0')
        t('=crosshair', 'crosshair')
        t('', '0')
        t('>help', 'help')
        t('>wait', 'wait')
        t('<', 'help')
        t('<', '0')
        t('default,help', 'help')
        t('<', '0')
        t('>default,help', 'help')
        t('<', 'default')
        t('<', '0')
        t('=left_ptr', 'default')
        t('=fleur', 'move')

    def test_color_profile(self):
        from kitty.fast_data_types import patch_color_profiles
        opts = self.set_options({'palette_generate': 'fixed'})
        c = ColorProfile(opts)
        for i in range(8):
            col = getattr(opts, f'color{i}')
            self.ae(c.as_color(i << 8 | 1), col)
        for i in range(16, 256):
            self.assertIsNone(getattr(opts, f'color{i}'))
        self.ae(c.as_color(255 << 8 | 1), Color(0xee, 0xee, 0xee))
        s = self.create_screen()
        s.color_profile.reload_from_opts(opts)
        def q(send, expected=None):
            s.callbacks.clear()
            parse_bytes(s, b'\x1b]21;' + ';'.join(f'{k}={v}' for k, v in send.items()).encode() + b'\a')
            self.ae(s.callbacks.color_control_responses, [expected] if expected else [])
        q({k: '?' for k in 'background foreground 213 unknown'.split()}, {
            'background': opts.background, 'foreground': opts.foreground,
            '213': Color(255, 135, 255), 'unknown': '?'})
        q({'background':'aquamarine'})
        q({'background':'?', 'selection_background': '?'}, {
            'background': Color.parse_color('Aquamarine'), 'selection_background': s.color_profile.highlight_bg})
        q({'selection_background': ''})
        self.assertIsNone(s.color_profile.highlight_bg)
        q({'selection_background': '?'}, {'selection_background': ''})
        self.assertTrue(s.color_profile.palette_color_is_generated(213))
        opts = self.set_options({'palette_generate': 'semantic'})
        q({'213': ''})
        q({'213': '?'}, {'213': Color(216, 125, 215)})
        self.assertTrue(s.color_profile.palette_color_is_generated(213))
        s.color_profile.reload_from_opts(opts)
        q({'transparent_background_color9': '?'}, {'transparent_background_color9': '?'})
        q({'transparent_background_color2': '?'}, {'transparent_background_color2': ''})
        q({'transparent_background_color2': 'red@0.5'})
        q({'transparent_background_color2': '?'}, {'transparent_background_color2': (Color(255, 0, 0), 126)})
        q({'transparent_background_color2': '#ffffff@-1'})
        q({'transparent_background_color2': '?'}, {'transparent_background_color2': (Color(255, 255, 255), 255)})
        opts.color114 = Color(1, 1, 4)
        s.color_profile.reload_from_opts(opts)
        self.assertTrue(s.color_profile.palette_color_is_generated(213))
        self.assertFalse(s.color_profile.palette_color_is_generated(114))
        q({'213': '?'}, {'213': Color(216, 125, 215)})
        patch_color_profiles(
            {'background': Color(255, 255, 255), 'foreground': Color(0, 0, 0)}, (), (s.color_profile,), True)
        self.assertTrue(s.color_profile.palette_color_is_generated(213))
        self.assertFalse(s.color_profile.palette_color_is_generated(114))
        q({'213': '?'}, {'213': Color(216, 125, 215)})


    def test_multi_cursors(self):
        s = self.create_screen()
        c = s.callbacks
        # Test detection
        def ec(payload=''):
            return f'\x1b[>{payload} q'.encode()  # ]
        parse_bytes(s, ec())
        self.ae(c.wtcbuf, ec('1;2;3;29;30;40;100;101'))

        def current() -> dict[int, tuple[int, int]]:
            ans = {}
            c.clear()
            parse_bytes(s, ec('100'))
            for entry in c.wtcbuf[6:-2].decode().split(';'):
                if entry:
                    which, _, y, x = map(int, entry.split(':'))
                    ans.setdefault(which, set()).add((x-1, y-1))
            return ans
        self.ae({}, current())

        def a(which: int, *positions: tuple[int, int], region=None) -> dict[int, tuple[int, int]]:
            if positions:
                buf = [f'\x1b[>{which};']  # ]
                buf.extend(f'2:{y+1}:{x+1};' for x, y in positions)
                parse_bytes(s, ''.join(buf).encode() + b' q')
            if region:
                if region is True:
                    parse_bytes(s, ec(f'{which};4'))
                else:
                    left, top, right, bottom = region
                    parse_bytes(s, ec(f'{which};4:{top+1}:{left+1}:{bottom+1}:{right+1}'))
            return current()

        self.ae(a(1, region=True), {1:{(x, y) for x in range(s.columns) for y in range(s.lines)}})
        self.ae(a(0, region=True), {})
        self.ae(a(29, region=(1, 2, 2, 3)), {29: {(1, 2), (2, 2), (1, 3), (2, 3)}})
        self.ae(a(2, (1, 2), (1, 3)), {29: {(2, 3), (2, 2)}, 2: {(1, 2), (1, 3)}})
        self.ae(a(0, (1, 2), (2, 3)), {29: {(2, 2)}, 2: {(1, 3)}})
        self.ae(a(0, region=True), {})
        s.cursor.x, s.cursor.y = 1, 2
        parse_bytes(s, ec('3;0'))
        self.ae(current(), {3: {(1, 2)}})
        parse_bytes(s, ec('3;2:3'))
        self.ae(current(), {3: {(1, 2)}})
        parse_bytes(s, ec('0;4:3:1:4'))
        self.ae(current(), {})

        def sc(op, r=0, g=0, b=0, slot=40):
            parse_bytes(s, ec(f'{slot};{op}:{r}:{g}:{b}'))
            c.clear()
            parse_bytes(s, ec('101'))
            for x in c.wtcbuf[3:-2].decode().split(';')[1:]:
                parts = x.split(':')
                if int(parts[0]) == slot:
                    if op < 2:
                        self.ae(op, int(parts[1]))
                    elif op == 2:
                        self.ae((op, r, g, b), tuple(map(int, parts[1:])))
                    else:
                        self.ae((op, r), tuple(map(int, parts[1:])))
                    break
        for slot in (40, 30):
            sc(0, slot=slot)
            sc(1, slot=slot)
            sc(2, 1, 2, 3, slot=slot)
            sc(5, 13, slot=slot)

    def test_soft_reset(self):
        SOFT_RESET = b'\x1b[!p'  # DECSTR sequence

        # Screen content is preserved (unlike hard reset)
        s = self.create_screen()
        s.draw('hello')
        parse_bytes(s, SOFT_RESET)
        self.ae(str(s.line(0)), 'hello')

        # Hard reset clears screen content; soft reset does not
        s = self.create_screen()
        s.draw('hello')
        s.reset()
        self.ae(str(s.line(0)), '')

        # Cursor SGR attributes are cleared
        s = self.create_screen()
        s.select_graphic_rendition(1)   # bold
        s.select_graphic_rendition(31)  # red fg
        self.assertTrue(s.cursor.bold)
        self.assertNotEqual(s.cursor.fg, 0)
        parse_bytes(s, SOFT_RESET)
        self.assertFalse(s.cursor.bold)
        self.ae(s.cursor.fg, 0)

        # Cursor position is preserved
        s = self.create_screen()
        s.cursor_position(3, 4)
        self.ae((s.cursor.y, s.cursor.x), (2, 3))
        parse_bytes(s, SOFT_RESET)
        self.ae((s.cursor.y, s.cursor.x), (2, 3))

        # Insert mode (IRM) is cleared
        s = self.create_screen()
        s.draw('abcde')
        s.set_mode(IRM)
        parse_bytes(s, SOFT_RESET)
        # without IRM, drawing overwrites
        s.cursor.x = 0
        s.draw('X')
        self.ae(str(s.line(0)), 'Xbcde')

        # Alternate screen is NOT exited on soft reset
        s = self.create_screen()
        parse_bytes(s, b'\x1b[?1049h')  # enter alternate screen
        self.assertFalse(s.is_main_linebuf())
        parse_bytes(s, SOFT_RESET)
        self.assertFalse(s.is_main_linebuf())
        s.reset()
        self.assertTrue(s.is_main_linebuf())


def detect_url(self, scale=1):
    s = self.create_screen(cols=30 * scale)

    def ae(expected, x=3, y=0):
        s.detect_url(x * scale, y * scale)
        url = ''.join(s.text_for_marked_url())
        self.assertEqual(expected, url)

    def t(url, x=0, y=0, before='', after='', expected=''):
        s.reset()
        s.cursor.x = x
        s.cursor.y = y
        text = before + url + after
        if scale == 1:
            s.draw(text)
        else:
            draw_multicell(s, text, scale=scale)
        ae(expected or url, x=x + 1 + len(before), y=y)


    t('http://moo.com')
    t('http://moo.com/something?else=+&what-')
    t('http://moo.com#fragme')
    for (st, e) in '() {} [] <>'.split():
        t('http://moo.com', before=st, after=e)
    for trailer in ')-=':
        t('http://moo.com' + trailer)
    for trailer in '{}([<>':   # )]>
        t('http://moo.com', after=trailer)
    if scale == 1:
        t('http://moo.com', x=s.columns - 9)
    t('https://wraps-by-one-char.com', before='[', after=']')
    t('http://[::1]:8080')
    t('https://wr[aps-by-one-ch]ar.com')
    t('http://[::1]:8080/x', after='[')  # ]
    t('http://[::1]:8080/x]y34', expected='http://[::1]:8080/x')
    t('https://wraps-by-one-char.com[]/x', after='[')  # ]
    t('https://t.me/#1_*_*_*_*')
