1"""Test sidebar, coverage 85%"""
2from textwrap import dedent
3import sys
4
5from itertools import chain
6import unittest
7import unittest.mock
8from test.support import requires, swap_attr
9from test import support
10import tkinter as tk
11from idlelib.idle_test.tkinter_testing_utils import run_in_tk_mainloop
12
13from idlelib.delegator import Delegator
14from idlelib.editor import fixwordbreaks
15from idlelib.percolator import Percolator
16import idlelib.pyshell
17from idlelib.pyshell import fix_x11_paste, PyShell, PyShellFileList
18from idlelib.run import fix_scaling
19import idlelib.sidebar
20from idlelib.sidebar import get_end_linenumber, get_lineno
21
22
23class Dummy_editwin:
24    def __init__(self, text):
25        self.text = text
26        self.text_frame = self.text.master
27        self.per = Percolator(text)
28        self.undo = Delegator()
29        self.per.insertfilter(self.undo)
30
31    def setvar(self, name, value):
32        pass
33
34    def getlineno(self, index):
35        return int(float(self.text.index(index)))
36
37
38class LineNumbersTest(unittest.TestCase):
39
40    @classmethod
41    def setUpClass(cls):
42        requires('gui')
43        cls.root = tk.Tk()
44        cls.root.withdraw()
45
46        cls.text_frame = tk.Frame(cls.root)
47        cls.text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
48        cls.text_frame.rowconfigure(1, weight=1)
49        cls.text_frame.columnconfigure(1, weight=1)
50
51        cls.text = tk.Text(cls.text_frame, width=80, height=24, wrap=tk.NONE)
52        cls.text.grid(row=1, column=1, sticky=tk.NSEW)
53
54        cls.editwin = Dummy_editwin(cls.text)
55        cls.editwin.vbar = tk.Scrollbar(cls.text_frame)
56
57    @classmethod
58    def tearDownClass(cls):
59        cls.editwin.per.close()
60        cls.root.update_idletasks()
61        cls.root.destroy()
62        del cls.text, cls.text_frame, cls.editwin, cls.root
63
64    def setUp(self):
65        self.linenumber = idlelib.sidebar.LineNumbers(self.editwin)
66
67        self.highlight_cfg = {"background": '#abcdef',
68                              "foreground": '#123456'}
69        orig_idleConf_GetHighlight = idlelib.sidebar.idleConf.GetHighlight
70        def mock_idleconf_GetHighlight(theme, element):
71            if element == 'linenumber':
72                return self.highlight_cfg
73            return orig_idleConf_GetHighlight(theme, element)
74        GetHighlight_patcher = unittest.mock.patch.object(
75            idlelib.sidebar.idleConf, 'GetHighlight', mock_idleconf_GetHighlight)
76        GetHighlight_patcher.start()
77        self.addCleanup(GetHighlight_patcher.stop)
78
79        self.font_override = 'TkFixedFont'
80        def mock_idleconf_GetFont(root, configType, section):
81            return self.font_override
82        GetFont_patcher = unittest.mock.patch.object(
83            idlelib.sidebar.idleConf, 'GetFont', mock_idleconf_GetFont)
84        GetFont_patcher.start()
85        self.addCleanup(GetFont_patcher.stop)
86
87    def tearDown(self):
88        self.text.delete('1.0', 'end')
89
90    def get_selection(self):
91        return tuple(map(str, self.text.tag_ranges('sel')))
92
93    def get_line_screen_position(self, line):
94        bbox = self.linenumber.sidebar_text.bbox(f'{line}.end -1c')
95        x = bbox[0] + 2
96        y = bbox[1] + 2
97        return x, y
98
99    def assert_state_disabled(self):
100        state = self.linenumber.sidebar_text.config()['state']
101        self.assertEqual(state[-1], tk.DISABLED)
102
103    def get_sidebar_text_contents(self):
104        return self.linenumber.sidebar_text.get('1.0', tk.END)
105
106    def assert_sidebar_n_lines(self, n_lines):
107        expected = '\n'.join(chain(map(str, range(1, n_lines + 1)), ['']))
108        self.assertEqual(self.get_sidebar_text_contents(), expected)
109
110    def assert_text_equals(self, expected):
111        return self.assertEqual(self.text.get('1.0', 'end'), expected)
112
113    def test_init_empty(self):
114        self.assert_sidebar_n_lines(1)
115
116    def test_init_not_empty(self):
117        self.text.insert('insert', 'foo bar\n'*3)
118        self.assert_text_equals('foo bar\n'*3 + '\n')
119        self.assert_sidebar_n_lines(4)
120
121    def test_toggle_linenumbering(self):
122        self.assertEqual(self.linenumber.is_shown, False)
123        self.linenumber.show_sidebar()
124        self.assertEqual(self.linenumber.is_shown, True)
125        self.linenumber.hide_sidebar()
126        self.assertEqual(self.linenumber.is_shown, False)
127        self.linenumber.hide_sidebar()
128        self.assertEqual(self.linenumber.is_shown, False)
129        self.linenumber.show_sidebar()
130        self.assertEqual(self.linenumber.is_shown, True)
131        self.linenumber.show_sidebar()
132        self.assertEqual(self.linenumber.is_shown, True)
133
134    def test_insert(self):
135        self.text.insert('insert', 'foobar')
136        self.assert_text_equals('foobar\n')
137        self.assert_sidebar_n_lines(1)
138        self.assert_state_disabled()
139
140        self.text.insert('insert', '\nfoo')
141        self.assert_text_equals('foobar\nfoo\n')
142        self.assert_sidebar_n_lines(2)
143        self.assert_state_disabled()
144
145        self.text.insert('insert', 'hello\n'*2)
146        self.assert_text_equals('foobar\nfoohello\nhello\n\n')
147        self.assert_sidebar_n_lines(4)
148        self.assert_state_disabled()
149
150        self.text.insert('insert', '\nworld')
151        self.assert_text_equals('foobar\nfoohello\nhello\n\nworld\n')
152        self.assert_sidebar_n_lines(5)
153        self.assert_state_disabled()
154
155    def test_delete(self):
156        self.text.insert('insert', 'foobar')
157        self.assert_text_equals('foobar\n')
158        self.text.delete('1.1', '1.3')
159        self.assert_text_equals('fbar\n')
160        self.assert_sidebar_n_lines(1)
161        self.assert_state_disabled()
162
163        self.text.insert('insert', 'foo\n'*2)
164        self.assert_text_equals('fbarfoo\nfoo\n\n')
165        self.assert_sidebar_n_lines(3)
166        self.assert_state_disabled()
167
168        # Deleting up to "2.end" doesn't delete the final newline.
169        self.text.delete('2.0', '2.end')
170        self.assert_text_equals('fbarfoo\n\n\n')
171        self.assert_sidebar_n_lines(3)
172        self.assert_state_disabled()
173
174        self.text.delete('1.3', 'end')
175        self.assert_text_equals('fba\n')
176        self.assert_sidebar_n_lines(1)
177        self.assert_state_disabled()
178
179        # Text widgets always keep a single '\n' character at the end.
180        self.text.delete('1.0', 'end')
181        self.assert_text_equals('\n')
182        self.assert_sidebar_n_lines(1)
183        self.assert_state_disabled()
184
185    def test_sidebar_text_width(self):
186        """
187        Test that linenumber text widget is always at the minimum
188        width
189        """
190        def get_width():
191            return self.linenumber.sidebar_text.config()['width'][-1]
192
193        self.assert_sidebar_n_lines(1)
194        self.assertEqual(get_width(), 1)
195
196        self.text.insert('insert', 'foo')
197        self.assert_sidebar_n_lines(1)
198        self.assertEqual(get_width(), 1)
199
200        self.text.insert('insert', 'foo\n'*8)
201        self.assert_sidebar_n_lines(9)
202        self.assertEqual(get_width(), 1)
203
204        self.text.insert('insert', 'foo\n')
205        self.assert_sidebar_n_lines(10)
206        self.assertEqual(get_width(), 2)
207
208        self.text.insert('insert', 'foo\n')
209        self.assert_sidebar_n_lines(11)
210        self.assertEqual(get_width(), 2)
211
212        self.text.delete('insert -1l linestart', 'insert linestart')
213        self.assert_sidebar_n_lines(10)
214        self.assertEqual(get_width(), 2)
215
216        self.text.delete('insert -1l linestart', 'insert linestart')
217        self.assert_sidebar_n_lines(9)
218        self.assertEqual(get_width(), 1)
219
220        self.text.insert('insert', 'foo\n'*90)
221        self.assert_sidebar_n_lines(99)
222        self.assertEqual(get_width(), 2)
223
224        self.text.insert('insert', 'foo\n')
225        self.assert_sidebar_n_lines(100)
226        self.assertEqual(get_width(), 3)
227
228        self.text.insert('insert', 'foo\n')
229        self.assert_sidebar_n_lines(101)
230        self.assertEqual(get_width(), 3)
231
232        self.text.delete('insert -1l linestart', 'insert linestart')
233        self.assert_sidebar_n_lines(100)
234        self.assertEqual(get_width(), 3)
235
236        self.text.delete('insert -1l linestart', 'insert linestart')
237        self.assert_sidebar_n_lines(99)
238        self.assertEqual(get_width(), 2)
239
240        self.text.delete('50.0 -1c', 'end -1c')
241        self.assert_sidebar_n_lines(49)
242        self.assertEqual(get_width(), 2)
243
244        self.text.delete('5.0 -1c', 'end -1c')
245        self.assert_sidebar_n_lines(4)
246        self.assertEqual(get_width(), 1)
247
248        # Text widgets always keep a single '\n' character at the end.
249        self.text.delete('1.0', 'end -1c')
250        self.assert_sidebar_n_lines(1)
251        self.assertEqual(get_width(), 1)
252
253    # The following tests are temporarily disabled due to relying on
254    # simulated user input and inspecting which text is selected, which
255    # are fragile and can fail when several GUI tests are run in parallel
256    # or when the windows created by the test lose focus.
257    #
258    # TODO: Re-work these tests or remove them from the test suite.
259
260    @unittest.skip('test disabled')
261    def test_click_selection(self):
262        self.linenumber.show_sidebar()
263        self.text.insert('1.0', 'one\ntwo\nthree\nfour\n')
264        self.root.update()
265
266        # Click on the second line.
267        x, y = self.get_line_screen_position(2)
268        self.linenumber.sidebar_text.event_generate('<Button-1>', x=x, y=y)
269        self.linenumber.sidebar_text.update()
270        self.root.update()
271
272        self.assertEqual(self.get_selection(), ('2.0', '3.0'))
273
274    def simulate_drag(self, start_line, end_line):
275        start_x, start_y = self.get_line_screen_position(start_line)
276        end_x, end_y = self.get_line_screen_position(end_line)
277
278        self.linenumber.sidebar_text.event_generate('<Button-1>',
279                                                    x=start_x, y=start_y)
280        self.root.update()
281
282        def lerp(a, b, steps):
283            """linearly interpolate from a to b (inclusive) in equal steps"""
284            last_step = steps - 1
285            for i in range(steps):
286                yield ((last_step - i) / last_step) * a + (i / last_step) * b
287
288        for x, y in zip(
289                map(int, lerp(start_x, end_x, steps=11)),
290                map(int, lerp(start_y, end_y, steps=11)),
291        ):
292            self.linenumber.sidebar_text.event_generate('<B1-Motion>', x=x, y=y)
293            self.root.update()
294
295        self.linenumber.sidebar_text.event_generate('<ButtonRelease-1>',
296                                                    x=end_x, y=end_y)
297        self.root.update()
298
299    @unittest.skip('test disabled')
300    def test_drag_selection_down(self):
301        self.linenumber.show_sidebar()
302        self.text.insert('1.0', 'one\ntwo\nthree\nfour\nfive\n')
303        self.root.update()
304
305        # Drag from the second line to the fourth line.
306        self.simulate_drag(2, 4)
307        self.assertEqual(self.get_selection(), ('2.0', '5.0'))
308
309    @unittest.skip('test disabled')
310    def test_drag_selection_up(self):
311        self.linenumber.show_sidebar()
312        self.text.insert('1.0', 'one\ntwo\nthree\nfour\nfive\n')
313        self.root.update()
314
315        # Drag from the fourth line to the second line.
316        self.simulate_drag(4, 2)
317        self.assertEqual(self.get_selection(), ('2.0', '5.0'))
318
319    def test_scroll(self):
320        self.linenumber.show_sidebar()
321        self.text.insert('1.0', 'line\n' * 100)
322        self.root.update()
323
324        # Scroll down 10 lines.
325        self.text.yview_scroll(10, 'unit')
326        self.root.update()
327        self.assertEqual(self.text.index('@0,0'), '11.0')
328        self.assertEqual(self.linenumber.sidebar_text.index('@0,0'), '11.0')
329
330        # Generate a mouse-wheel event and make sure it scrolled up or down.
331        # The meaning of the "delta" is OS-dependent, so this just checks for
332        # any change.
333        self.linenumber.sidebar_text.event_generate('<MouseWheel>',
334                                                    x=0, y=0,
335                                                    delta=10)
336        self.root.update()
337        self.assertNotEqual(self.text.index('@0,0'), '11.0')
338        self.assertNotEqual(self.linenumber.sidebar_text.index('@0,0'), '11.0')
339
340    def test_font(self):
341        ln = self.linenumber
342
343        orig_font = ln.sidebar_text['font']
344        test_font = 'TkTextFont'
345        self.assertNotEqual(orig_font, test_font)
346
347        # Ensure line numbers aren't shown.
348        ln.hide_sidebar()
349
350        self.font_override = test_font
351        # Nothing breaks when line numbers aren't shown.
352        ln.update_font()
353
354        # Activate line numbers, previous font change is immediately effective.
355        ln.show_sidebar()
356        self.assertEqual(ln.sidebar_text['font'], test_font)
357
358        # Call the font update with line numbers shown, change is picked up.
359        self.font_override = orig_font
360        ln.update_font()
361        self.assertEqual(ln.sidebar_text['font'], orig_font)
362
363    def test_highlight_colors(self):
364        ln = self.linenumber
365
366        orig_colors = dict(self.highlight_cfg)
367        test_colors = {'background': '#222222', 'foreground': '#ffff00'}
368
369        def assert_colors_are_equal(colors):
370            self.assertEqual(ln.sidebar_text['background'], colors['background'])
371            self.assertEqual(ln.sidebar_text['foreground'], colors['foreground'])
372
373        # Ensure line numbers aren't shown.
374        ln.hide_sidebar()
375
376        self.highlight_cfg = test_colors
377        # Nothing breaks with inactive line numbers.
378        ln.update_colors()
379
380        # Show line numbers, previous colors change is immediately effective.
381        ln.show_sidebar()
382        assert_colors_are_equal(test_colors)
383
384        # Call colors update with no change to the configured colors.
385        ln.update_colors()
386        assert_colors_are_equal(test_colors)
387
388        # Call the colors update with line numbers shown, change is picked up.
389        self.highlight_cfg = orig_colors
390        ln.update_colors()
391        assert_colors_are_equal(orig_colors)
392
393
394class ShellSidebarTest(unittest.TestCase):
395    root: tk.Tk = None
396    shell: PyShell = None
397
398    @classmethod
399    def setUpClass(cls):
400        requires('gui')
401
402        cls.root = root = tk.Tk()
403        root.withdraw()
404
405        fix_scaling(root)
406        fixwordbreaks(root)
407        fix_x11_paste(root)
408
409        cls.flist = flist = PyShellFileList(root)
410        # See #43981 about macosx.setupApp(root, flist) causing failure.
411        root.update_idletasks()
412
413        cls.init_shell()
414
415    @classmethod
416    def tearDownClass(cls):
417        if cls.shell is not None:
418            cls.shell.executing = False
419            cls.shell.close()
420            cls.shell = None
421        cls.flist = None
422        cls.root.update_idletasks()
423        cls.root.destroy()
424        cls.root = None
425
426    @classmethod
427    def init_shell(cls):
428        cls.shell = cls.flist.open_shell()
429        cls.shell.pollinterval = 10
430        cls.root.update()
431        cls.n_preface_lines = get_lineno(cls.shell.text, 'end-1c') - 1
432
433    @classmethod
434    def reset_shell(cls):
435        cls.shell.per.bottom.delete(f'{cls.n_preface_lines+1}.0', 'end-1c')
436        cls.shell.shell_sidebar.update_sidebar()
437        cls.root.update()
438
439    def setUp(self):
440        # In some test environments, e.g. Azure Pipelines (as of
441        # Apr. 2021), sys.stdout is changed between tests. However,
442        # PyShell relies on overriding sys.stdout when run without a
443        # sub-process (as done here; see setUpClass).
444        self._saved_stdout = None
445        if sys.stdout != self.shell.stdout:
446            self._saved_stdout = sys.stdout
447            sys.stdout = self.shell.stdout
448
449        self.reset_shell()
450
451    def tearDown(self):
452        if self._saved_stdout is not None:
453            sys.stdout = self._saved_stdout
454
455    def get_sidebar_lines(self):
456        canvas = self.shell.shell_sidebar.canvas
457        texts = list(canvas.find(tk.ALL))
458        texts_by_y_coords = {
459            canvas.bbox(text)[1]: canvas.itemcget(text, 'text')
460            for text in texts
461        }
462        line_y_coords = self.get_shell_line_y_coords()
463        return [texts_by_y_coords.get(y, None) for y in line_y_coords]
464
465    def assert_sidebar_lines_end_with(self, expected_lines):
466        self.shell.shell_sidebar.update_sidebar()
467        self.assertEqual(
468            self.get_sidebar_lines()[-len(expected_lines):],
469            expected_lines,
470        )
471
472    def get_shell_line_y_coords(self):
473        text = self.shell.text
474        y_coords = []
475        index = text.index("@0,0")
476        if index.split('.', 1)[1] != '0':
477            index = text.index(f"{index} +1line linestart")
478        while (lineinfo := text.dlineinfo(index)) is not None:
479            y_coords.append(lineinfo[1])
480            index = text.index(f"{index} +1line")
481        return y_coords
482
483    def get_sidebar_line_y_coords(self):
484        canvas = self.shell.shell_sidebar.canvas
485        texts = list(canvas.find(tk.ALL))
486        texts.sort(key=lambda text: canvas.bbox(text)[1])
487        return [canvas.bbox(text)[1] for text in texts]
488
489    def assert_sidebar_lines_synced(self):
490        self.assertLessEqual(
491            set(self.get_sidebar_line_y_coords()),
492            set(self.get_shell_line_y_coords()),
493        )
494
495    def do_input(self, input):
496        shell = self.shell
497        text = shell.text
498        for line_index, line in enumerate(input.split('\n')):
499            if line_index > 0:
500                text.event_generate('<<newline-and-indent>>')
501            text.insert('insert', line, 'stdin')
502
503    def test_initial_state(self):
504        sidebar_lines = self.get_sidebar_lines()
505        self.assertEqual(
506            sidebar_lines,
507            [None] * (len(sidebar_lines) - 1) + ['>>>'],
508        )
509        self.assert_sidebar_lines_synced()
510
511    @run_in_tk_mainloop()
512    def test_single_empty_input(self):
513        self.do_input('\n')
514        yield
515        self.assert_sidebar_lines_end_with(['>>>', '>>>'])
516
517    @run_in_tk_mainloop()
518    def test_single_line_statement(self):
519        self.do_input('1\n')
520        yield
521        self.assert_sidebar_lines_end_with(['>>>', None, '>>>'])
522
523    @run_in_tk_mainloop()
524    def test_multi_line_statement(self):
525        # Block statements are not indented because IDLE auto-indents.
526        self.do_input(dedent('''\
527            if True:
528            print(1)
529
530            '''))
531        yield
532        self.assert_sidebar_lines_end_with([
533            '>>>',
534            '...',
535            '...',
536            '...',
537            None,
538            '>>>',
539        ])
540
541    @run_in_tk_mainloop()
542    def test_single_long_line_wraps(self):
543        self.do_input('1' * 200 + '\n')
544        yield
545        self.assert_sidebar_lines_end_with(['>>>', None, '>>>'])
546        self.assert_sidebar_lines_synced()
547
548    @run_in_tk_mainloop()
549    def test_squeeze_multi_line_output(self):
550        shell = self.shell
551        text = shell.text
552
553        self.do_input('print("a\\nb\\nc")\n')
554        yield
555        self.assert_sidebar_lines_end_with(['>>>', None, None, None, '>>>'])
556
557        text.mark_set('insert', f'insert -1line linestart')
558        text.event_generate('<<squeeze-current-text>>')
559        yield
560        self.assert_sidebar_lines_end_with(['>>>', None, '>>>'])
561        self.assert_sidebar_lines_synced()
562
563        shell.squeezer.expandingbuttons[0].expand()
564        yield
565        self.assert_sidebar_lines_end_with(['>>>', None, None, None, '>>>'])
566        self.assert_sidebar_lines_synced()
567
568    @run_in_tk_mainloop()
569    def test_interrupt_recall_undo_redo(self):
570        text = self.shell.text
571        # Block statements are not indented because IDLE auto-indents.
572        initial_sidebar_lines = self.get_sidebar_lines()
573
574        self.do_input(dedent('''\
575            if True:
576            print(1)
577            '''))
578        yield
579        self.assert_sidebar_lines_end_with(['>>>', '...', '...'])
580        with_block_sidebar_lines = self.get_sidebar_lines()
581        self.assertNotEqual(with_block_sidebar_lines, initial_sidebar_lines)
582
583        # Control-C
584        text.event_generate('<<interrupt-execution>>')
585        yield
586        self.assert_sidebar_lines_end_with(['>>>', '...', '...', None, '>>>'])
587
588        # Recall previous via history
589        text.event_generate('<<history-previous>>')
590        text.event_generate('<<interrupt-execution>>')
591        yield
592        self.assert_sidebar_lines_end_with(['>>>', '...', None, '>>>'])
593
594        # Recall previous via recall
595        text.mark_set('insert', text.index('insert -2l'))
596        text.event_generate('<<newline-and-indent>>')
597        yield
598
599        text.event_generate('<<undo>>')
600        yield
601        self.assert_sidebar_lines_end_with(['>>>'])
602
603        text.event_generate('<<redo>>')
604        yield
605        self.assert_sidebar_lines_end_with(['>>>', '...'])
606
607        text.event_generate('<<newline-and-indent>>')
608        text.event_generate('<<newline-and-indent>>')
609        yield
610        self.assert_sidebar_lines_end_with(
611            ['>>>', '...', '...', '...', None, '>>>']
612        )
613
614    @run_in_tk_mainloop()
615    def test_very_long_wrapped_line(self):
616        with support.adjust_int_max_str_digits(11_111), \
617                swap_attr(self.shell, 'squeezer', None):
618            self.do_input('x = ' + '1'*10_000 + '\n')
619            yield
620            self.assertEqual(self.get_sidebar_lines(), ['>>>'])
621
622    def test_font(self):
623        sidebar = self.shell.shell_sidebar
624
625        test_font = 'TkTextFont'
626
627        def mock_idleconf_GetFont(root, configType, section):
628            return test_font
629        GetFont_patcher = unittest.mock.patch.object(
630            idlelib.sidebar.idleConf, 'GetFont', mock_idleconf_GetFont)
631        GetFont_patcher.start()
632        def cleanup():
633            GetFont_patcher.stop()
634            sidebar.update_font()
635        self.addCleanup(cleanup)
636
637        def get_sidebar_font():
638            canvas = sidebar.canvas
639            texts = list(canvas.find(tk.ALL))
640            fonts = {canvas.itemcget(text, 'font') for text in texts}
641            self.assertEqual(len(fonts), 1)
642            return next(iter(fonts))
643
644        self.assertNotEqual(get_sidebar_font(), test_font)
645        sidebar.update_font()
646        self.assertEqual(get_sidebar_font(), test_font)
647
648    def test_highlight_colors(self):
649        sidebar = self.shell.shell_sidebar
650
651        test_colors = {"background": '#abcdef', "foreground": '#123456'}
652
653        orig_idleConf_GetHighlight = idlelib.sidebar.idleConf.GetHighlight
654        def mock_idleconf_GetHighlight(theme, element):
655            if element in ['linenumber', 'console']:
656                return test_colors
657            return orig_idleConf_GetHighlight(theme, element)
658        GetHighlight_patcher = unittest.mock.patch.object(
659            idlelib.sidebar.idleConf, 'GetHighlight',
660            mock_idleconf_GetHighlight)
661        GetHighlight_patcher.start()
662        def cleanup():
663            GetHighlight_patcher.stop()
664            sidebar.update_colors()
665        self.addCleanup(cleanup)
666
667        def get_sidebar_colors():
668            canvas = sidebar.canvas
669            texts = list(canvas.find(tk.ALL))
670            fgs = {canvas.itemcget(text, 'fill') for text in texts}
671            self.assertEqual(len(fgs), 1)
672            fg = next(iter(fgs))
673            bg = canvas.cget('background')
674            return {"background": bg, "foreground": fg}
675
676        self.assertNotEqual(get_sidebar_colors(), test_colors)
677        sidebar.update_colors()
678        self.assertEqual(get_sidebar_colors(), test_colors)
679
680    @run_in_tk_mainloop()
681    def test_mousewheel(self):
682        sidebar = self.shell.shell_sidebar
683        text = self.shell.text
684
685        # Enter a 100-line string to scroll the shell screen down.
686        self.do_input('x = """' + '\n'*100 + '"""\n')
687        yield
688        self.assertGreater(get_lineno(text, '@0,0'), 1)
689
690        last_lineno = get_end_linenumber(text)
691        self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0')))
692
693        # Scroll up using the <MouseWheel> event.
694        # The meaning of delta is platform-dependent.
695        delta = -1 if sys.platform == 'darwin' else 120
696        sidebar.canvas.event_generate('<MouseWheel>', x=0, y=0, delta=delta)
697        yield
698        if sys.platform != 'darwin':  # .update_idletasks() does not work.
699            self.assertIsNone(text.dlineinfo(text.index(f'{last_lineno}.0')))
700
701        # Scroll back down using the <Button-5> event.
702        sidebar.canvas.event_generate('<Button-5>', x=0, y=0)
703        yield
704        self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0')))
705
706    @run_in_tk_mainloop()
707    def test_copy(self):
708        sidebar = self.shell.shell_sidebar
709        text = self.shell.text
710
711        first_line = get_end_linenumber(text)
712
713        self.do_input(dedent('''\
714            if True:
715            print(1)
716
717            '''))
718        yield
719
720        text.tag_add('sel', f'{first_line}.0', 'end-1c')
721        selected_text = text.get('sel.first', 'sel.last')
722        self.assertTrue(selected_text.startswith('if True:\n'))
723        self.assertIn('\n1\n', selected_text)
724
725        text.event_generate('<<copy>>')
726        self.addCleanup(text.clipboard_clear)
727
728        copied_text = text.clipboard_get()
729        self.assertEqual(copied_text, selected_text)
730
731    @run_in_tk_mainloop()
732    def test_copy_with_prompts(self):
733        sidebar = self.shell.shell_sidebar
734        text = self.shell.text
735
736        first_line = get_end_linenumber(text)
737        self.do_input(dedent('''\
738            if True:
739                print(1)
740
741            '''))
742        yield
743
744        text.tag_add('sel', f'{first_line}.3', 'end-1c')
745        selected_text = text.get('sel.first', 'sel.last')
746        self.assertTrue(selected_text.startswith('True:\n'))
747
748        selected_lines_text = text.get('sel.first linestart', 'sel.last')
749        selected_lines = selected_lines_text.split('\n')
750        selected_lines.pop()  # Final '' is a split artifact, not a line.
751        # Expect a block of input and a single output line.
752        expected_prompts = \
753            ['>>>'] + ['...'] * (len(selected_lines) - 2) + [None]
754        selected_text_with_prompts = '\n'.join(
755            line if prompt is None else prompt + ' ' + line
756            for prompt, line in zip(expected_prompts,
757                                    selected_lines,
758                                    strict=True)
759        ) + '\n'
760
761        text.event_generate('<<copy-with-prompts>>')
762        self.addCleanup(text.clipboard_clear)
763
764        copied_text = text.clipboard_get()
765        self.assertEqual(copied_text, selected_text_with_prompts)
766
767
768if __name__ == '__main__':
769    unittest.main(verbosity=2)
770