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