From 7428643cd73eba622b6ac83f90c7fdfaff9f74c6 Mon Sep 17 00:00:00 2001 From: Tal Einat <532281+taleinat@users.noreply.github.com> Date: Wed, 28 Apr 2021 19:09:41 +0300 Subject: [PATCH 01/13] bpo-37903: implement shell sidebar mouse interactions --- Lib/idlelib/sidebar.py | 380 +++++++++++++++++++---------------------- 1 file changed, 178 insertions(+), 202 deletions(-) diff --git a/Lib/idlelib/sidebar.py b/Lib/idlelib/sidebar.py index a947961b858d68..a67cce58814489 100644 --- a/Lib/idlelib/sidebar.py +++ b/Lib/idlelib/sidebar.py @@ -70,56 +70,52 @@ def __init__(self, editwin): self.parent = editwin.text_frame self.text = editwin.text - _padx, pady = get_widget_padding(self.text) - self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE, - padx=2, pady=pady, - borderwidth=0, highlightthickness=0) - self.sidebar_text.config(state=tk.DISABLED) - self.text['yscrollcommand'] = self.redirect_yscroll_event + self.is_shown = False + + self.main_widget = self.init_widgets() + + self.bind_events() + self.update_font() self.update_colors() - self.is_shown = False + def init_widgets(self): + """Initialize the sidebar's widgets, returning the main widget.""" + raise NotImplementedError def update_font(self): """Update the sidebar text font, usually after config changes.""" - font = idleConf.GetFont(self.text, 'main', 'EditorWindow') - self._update_font(font) - - def _update_font(self, font): - self.sidebar_text['font'] = font + raise NotImplementedError def update_colors(self): """Update the sidebar text colors, usually after config changes.""" - colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'normal') - self._update_colors(foreground=colors['foreground'], - background=colors['background']) + raise NotImplementedError - def _update_colors(self, foreground, background): - self.sidebar_text.config( - fg=foreground, bg=background, - selectforeground=foreground, selectbackground=background, - inactiveselectbackground=background, - ) + def grid(self): + """Layout the widget, always using grid layout.""" + raise NotImplementedError def show_sidebar(self): if not self.is_shown: - self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW) + self.grid() self.is_shown = True def hide_sidebar(self): if self.is_shown: - self.sidebar_text.grid_forget() + self.main_widget.grid_forget() self.is_shown = False + def yscroll_event(self, *args, **kwargs): + """Hook for vertical scrolling for sub-classes to override.""" + raise NotImplementedError + def redirect_yscroll_event(self, *args, **kwargs): """Redirect vertical scrolling to the main editor text widget. The scroll bar is also updated. """ self.editwin.vbar.set(*args) - self.sidebar_text.yview_moveto(args[0]) - return 'break' + return self.yscroll_event(*args, **kwargs) def redirect_focusin_event(self, event): """Redirect focus-in events to the main editor text widget.""" @@ -138,57 +134,17 @@ def redirect_mousewheel_event(self, event): x=0, y=event.y, delta=event.delta) return 'break' - -class EndLineDelegator(Delegator): - """Generate callbacks with the current end line number. - - The provided callback is called after every insert and delete. - """ - def __init__(self, changed_callback): - Delegator.__init__(self) - self.changed_callback = changed_callback - - def insert(self, index, chars, tags=None): - self.delegate.insert(index, chars, tags) - self.changed_callback(get_end_linenumber(self.delegate)) - - def delete(self, index1, index2=None): - self.delegate.delete(index1, index2) - self.changed_callback(get_end_linenumber(self.delegate)) - - -class LineNumbers(BaseSideBar): - """Line numbers support for editor windows.""" - def __init__(self, editwin): - BaseSideBar.__init__(self, editwin) - self.prev_end = 1 - self._sidebar_width_type = type(self.sidebar_text['width']) - self.sidebar_text.config(state=tk.NORMAL) - self.sidebar_text.insert('insert', '1', 'linenumber') - self.sidebar_text.config(state=tk.DISABLED) - self.sidebar_text.config(takefocus=False, exportselection=False) - self.sidebar_text.tag_config('linenumber', justify=tk.RIGHT) - - self.bind_events() - - end = get_end_linenumber(self.text) - self.update_sidebar_text(end) - - end_line_delegator = EndLineDelegator(self.update_sidebar_text) - # Insert the delegator after the undo delegator, so that line numbers - # are properly updated after undo and redo actions. - self.editwin.per.insertfilterafter(filter=end_line_delegator, - after=self.editwin.undo) - def bind_events(self): + self.text['yscrollcommand'] = self.redirect_yscroll_event + # Ensure focus is always redirected to the main editor text widget. - self.sidebar_text.bind('', self.redirect_focusin_event) + self.main_widget.bind('', self.redirect_focusin_event) # Redirect mouse scrolling to the main editor text widget. # # Note that without this, scrolling with the mouse only scrolls # the line numbers. - self.sidebar_text.bind('', self.redirect_mousewheel_event) + self.main_widget.bind('', self.redirect_mousewheel_event) # Redirect mouse button events to the main editor text widget, # except for the left mouse button (1). @@ -197,7 +153,7 @@ def bind_events(self): def bind_mouse_event(event_name, target_event_name): handler = functools.partial(self.redirect_mousebutton_event, event_name=target_event_name) - self.sidebar_text.bind(event_name, handler) + self.main_widget.bind(event_name, handler) for button in [2, 3, 4, 5]: for event_name in (f'', @@ -214,83 +170,162 @@ def bind_mouse_event(event_name, target_event_name): bind_mouse_event(event_name, target_event_name=f'') - # This is set by b1_mousedown_handler() and read by - # drag_update_selection_and_insert_mark(), to know where dragging - # began. + # start_line is set upon to allow selecting a range of rows + # by dragging. It is cleared upon . start_line = None - # These are set by b1_motion_handler() and read by selection_handler(). - # last_y is passed this way since the mouse Y-coordinate is not - # available on selection event objects. last_yview is passed this way - # to recognize scrolling while the mouse isn't moving. - last_y = last_yview = None - def b1_mousedown_handler(event): - # select the entire line - lineno = int(float(self.sidebar_text.index(f"@0,{event.y}"))) + # last_y is initially set upon and is continuously updated + # upon , until or the mouse button is released. + # It is used in text_auto_scroll(), which is called repeatedly and + # does have a mouse event available. + last_y = None + + # auto_scrolling_after_id is set whenever text_auto_scroll is + # scheduled via .after(). It is used to stop the auto-scrolling + # upon , as well as to avoid scheduling the function several + # times in parallel. + auto_scrolling_after_id = None + + def drag_update_selection_and_insert_mark(y_coord): + """Helper function for drag and selection event handlers.""" + lineno = get_lineno(self.text, f"@0,{y_coord}") + a, b = sorted([start_line, lineno]) self.text.tag_remove("sel", "1.0", "end") - self.text.tag_add("sel", f"{lineno}.0", f"{lineno+1}.0") - self.text.mark_set("insert", f"{lineno+1}.0") + self.text.tag_add("sel", f"{a}.0", f"{b+1}.0") + self.text.mark_set("insert", + f"{lineno if lineno == a else lineno + 1}.0") - # remember this line in case this is the beginning of dragging + def b1_mousedown_handler(event): nonlocal start_line - start_line = lineno - self.sidebar_text.bind('', b1_mousedown_handler) + nonlocal last_y + start_line = int(float(self.text.index(f"@0,{event.y}"))) + last_y = event.y + + drag_update_selection_and_insert_mark(event.y) + self.main_widget.bind('', b1_mousedown_handler) def b1_mouseup_handler(event): # On mouse up, we're no longer dragging. Set the shared persistent # variables to None to represent this. nonlocal start_line nonlocal last_y - nonlocal last_yview start_line = None last_y = None - last_yview = None - self.sidebar_text.bind('', b1_mouseup_handler) - - def drag_update_selection_and_insert_mark(y_coord): - """Helper function for drag and selection event handlers.""" - lineno = int(float(self.sidebar_text.index(f"@0,{y_coord}"))) - a, b = sorted([start_line, lineno]) - self.text.tag_remove("sel", "1.0", "end") - self.text.tag_add("sel", f"{a}.0", f"{b+1}.0") - self.text.mark_set("insert", - f"{lineno if lineno == a else lineno + 1}.0") + self.text.event_generate('', x=0, y=event.y) + self.main_widget.bind('', b1_mouseup_handler) - # Special handling of dragging with mouse button 1. In "normal" text - # widgets this selects text, but the line numbers text widget has - # selection disabled. Still, dragging triggers some selection-related - # functionality under the hood. Specifically, dragging to above or - # below the text widget triggers scrolling, in a way that bypasses the - # other scrolling synchronization mechanisms.i - def b1_drag_handler(event, *args): + def b1_drag_handler(event): nonlocal last_y - nonlocal last_yview + if last_y is None: # i.e. if not currently dragging + return last_y = event.y - last_yview = self.sidebar_text.yview() - if not 0 <= last_y <= self.sidebar_text.winfo_height(): - self.text.yview_moveto(last_yview[0]) drag_update_selection_and_insert_mark(event.y) - self.sidebar_text.bind('', b1_drag_handler) - - # With mouse-drag scrolling fixed by the above, there is still an edge- - # case we need to handle: When drag-scrolling, scrolling can continue - # while the mouse isn't moving, leading to the above fix not scrolling - # properly. - def selection_handler(event): - if last_yview is None: - # This logic is only needed while dragging. + self.main_widget.bind('', b1_drag_handler) + + def text_auto_scroll(): + """Mimic Text auto-scrolling when dragging outside of it.""" + # See: https://github.com/tcltk/tk/blob/064ff9941b4b80b85916a8afe86a6c21fd388b54/library/text.tcl#L670 + nonlocal auto_scrolling_after_id + y = last_y + if y is None: + self.main_widget.after_cancel(auto_scrolling_after_id) + auto_scrolling_after_id = None return - yview = self.sidebar_text.yview() - if yview != last_yview: - self.text.yview_moveto(yview[0]) - drag_update_selection_and_insert_mark(last_y) - self.sidebar_text.bind('<>', selection_handler) + elif y < 0: + self.text.yview_scroll(-1 + y, 'pixels') + drag_update_selection_and_insert_mark(y) + elif y > self.main_widget.winfo_height(): + self.text.yview_scroll(1 + y - self.main_widget.winfo_height(), + 'pixels') + drag_update_selection_and_insert_mark(y) + auto_scrolling_after_id = \ + self.main_widget.after(50, text_auto_scroll) + + def b1_leave_handler(event): + # Schedule the initial call to text_auto_scroll(), if not already + # scheduled. + nonlocal auto_scrolling_after_id + if auto_scrolling_after_id is None: + nonlocal last_y + last_y = event.y + auto_scrolling_after_id = \ + self.main_widget.after(0, text_auto_scroll) + self.main_widget.bind('', b1_leave_handler) + + def b1_enter_handler(event): + # Cancel the scheduling of text_auto_scroll(), if it exists. + nonlocal auto_scrolling_after_id + if auto_scrolling_after_id is not None: + self.main_widget.after_cancel(auto_scrolling_after_id) + auto_scrolling_after_id = None + self.main_widget.bind('', b1_enter_handler) + + +class EndLineDelegator(Delegator): + """Generate callbacks with the current end line number. + + The provided callback is called after every insert and delete. + """ + def __init__(self, changed_callback): + Delegator.__init__(self) + self.changed_callback = changed_callback + + def insert(self, index, chars, tags=None): + self.delegate.insert(index, chars, tags) + self.changed_callback(get_end_linenumber(self.delegate)) + + def delete(self, index1, index2=None): + self.delegate.delete(index1, index2) + self.changed_callback(get_end_linenumber(self.delegate)) + + +class LineNumbers(BaseSideBar): + """Line numbers support for editor windows.""" + def __init__(self, editwin): + super().__init__(editwin) + + end_line_delegator = EndLineDelegator(self.update_sidebar_text) + # Insert the delegator after the undo delegator, so that line numbers + # are properly updated after undo and redo actions. + self.editwin.per.insertfilterafter(end_line_delegator, + after=self.editwin.undo) + + def init_widgets(self): + _padx, pady = get_widget_padding(self.text) + self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE, + padx=2, pady=pady, + borderwidth=0, highlightthickness=0) + self.sidebar_text.config(state=tk.DISABLED) + + self.prev_end = 1 + self._sidebar_width_type = type(self.sidebar_text['width']) + with temp_enable_text_widget(self.sidebar_text): + self.sidebar_text.insert('insert', '1', 'linenumber') + self.sidebar_text.config(takefocus=False, exportselection=False) + self.sidebar_text.tag_config('linenumber', justify=tk.RIGHT) + + end = get_end_linenumber(self.text) + self.update_sidebar_text(end) + + return self.sidebar_text + + def grid(self): + self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW) + + def update_font(self): + font = idleConf.GetFont(self.text, 'main', 'EditorWindow') + self.sidebar_text['font'] = font def update_colors(self): """Update the sidebar text colors, usually after config changes.""" colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') - self._update_colors(foreground=colors['foreground'], - background=colors['background']) + foreground = colors['foreground'] + background = colors['background'] + self.sidebar_text.config( + fg=foreground, bg=background, + selectforeground=foreground, selectbackground=background, + inactiveselectbackground=background, + ) def update_sidebar_text(self, end): """ @@ -319,6 +354,10 @@ def update_sidebar_text(self, end): self.prev_end = end + def yscroll_event(self, *args, **kwargs): + self.sidebar_text.yview_moveto(args[0]) + return 'break' + class WrappedLineHeightChangeDelegator(Delegator): def __init__(self, callback): @@ -361,22 +400,13 @@ def delete(self, index1, index2=None): self.callback() -class ShellSidebar: +class ShellSidebar(BaseSideBar): """Sidebar for the PyShell window, for prompts etc.""" def __init__(self, editwin): - self.editwin = editwin - self.parent = editwin.text_frame - self.text = editwin.text - - self.canvas = tk.Canvas(self.parent, width=30, - borderwidth=0, highlightthickness=0, - takefocus=False) - - self.bind_events() + super().__init__(editwin) change_delegator = \ WrappedLineHeightChangeDelegator(self.change_callback) - # Insert the TextChangeDelegator after the last delegator, so that # the sidebar reflects final changes to the text widget contents. d = self.editwin.per.top @@ -385,15 +415,18 @@ def __init__(self, editwin): d = d.delegate self.editwin.per.insertfilterafter(change_delegator, after=d) - self.text['yscrollcommand'] = self.yscroll_event - - self.is_shown = False + self.is_shown = True - self.update_font() - self.update_colors() + def init_widgets(self): + self.canvas = tk.Canvas(self.parent, width=30, + borderwidth=0, highlightthickness=0, + takefocus=False) self.update_sidebar() + self.grid() + return self.canvas + + def grid(self): self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0) - self.is_shown = True def change_callback(self): if self.is_shown: @@ -430,7 +463,6 @@ def yscroll_event(self, *args, **kwargs): The scroll bar is also updated. """ - self.editwin.vbar.set(*args) self.change_callback() return 'break' @@ -440,9 +472,6 @@ def update_font(self): tk_font = Font(self.text, font=font) char_width = max(tk_font.measure(char) for char in ['>', '.']) self.canvas.configure(width=char_width * 3 + 4) - self._update_font(font) - - def _update_font(self, font): self.font = font self.change_callback() @@ -450,65 +479,12 @@ def update_colors(self): """Update the sidebar text colors, usually after config changes.""" linenumbers_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') prompt_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'console') - self._update_colors(foreground=prompt_colors['foreground'], - background=linenumbers_colors['background']) - - def _update_colors(self, foreground, background): + foreground = prompt_colors['foreground'] + background = linenumbers_colors['background'] self.colors = (foreground, background) - self.canvas.configure(background=self.colors[1]) + self.canvas.configure(background=background) self.change_callback() - def redirect_focusin_event(self, event): - """Redirect focus-in events to the main editor text widget.""" - self.text.focus_set() - return 'break' - - def redirect_mousebutton_event(self, event, event_name): - """Redirect mouse button events to the main editor text widget.""" - self.text.focus_set() - self.text.event_generate(event_name, x=0, y=event.y) - return 'break' - - def redirect_mousewheel_event(self, event): - """Redirect mouse wheel events to the editwin text widget.""" - self.text.event_generate('', - x=0, y=event.y, delta=event.delta) - return 'break' - - def bind_events(self): - # Ensure focus is always redirected to the main editor text widget. - self.canvas.bind('', self.redirect_focusin_event) - - # Redirect mouse scrolling to the main editor text widget. - # - # Note that without this, scrolling with the mouse only scrolls - # the line numbers. - self.canvas.bind('', self.redirect_mousewheel_event) - - # Redirect mouse button events to the main editor text widget, - # except for the left mouse button (1). - # - # Note: X-11 sends Button-4 and Button-5 events for the scroll wheel. - def bind_mouse_event(event_name, target_event_name): - handler = functools.partial(self.redirect_mousebutton_event, - event_name=target_event_name) - self.canvas.bind(event_name, handler) - - for button in [2, 3, 4, 5]: - for event_name in (f'', - f'', - f'', - ): - bind_mouse_event(event_name, target_event_name=event_name) - - # Convert double- and triple-click events to normal click events, - # since event_generate() doesn't allow generating such events. - for event_name in (f'', - f'', - ): - bind_mouse_event(event_name, - target_event_name=f'') - def _linenumbers_drag_scrolling(parent): # htest # from idlelib.idle_test.test_sidebar import Dummy_editwin From c7dc3f49b2af18c8b346842d2eb2900517e874e8 Mon Sep 17 00:00:00 2001 From: Tal Einat <532281+taleinat@users.noreply.github.com> Date: Thu, 29 Apr 2021 12:24:17 +0300 Subject: [PATCH 02/13] working shell sidebar context menu --- Lib/idlelib/sidebar.py | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/Lib/idlelib/sidebar.py b/Lib/idlelib/sidebar.py index a67cce58814489..f9cd50f11fc6ab 100644 --- a/Lib/idlelib/sidebar.py +++ b/Lib/idlelib/sidebar.py @@ -9,6 +9,7 @@ from tkinter.font import Font from idlelib.config import idleConf from idlelib.delegator import Delegator +from idlelib import macosx def get_lineno(text, index): @@ -425,6 +426,50 @@ def init_widgets(self): self.grid() return self.canvas + def bind_events(self): + super().bind_events() + + self.main_widget.bind( + # AquaTk defines <2> as the right button, not <3>. + "" if macosx.isAquaTk() else "", + self.context_menu_event, + ) + + def context_menu_event(self, event): + rmenu = tk.Menu(self.main_widget, tearoff=0) + rmenu.add_command(label='Copy', command=self.rmenu_copy_handler) + rmenu.add_command(label='Copy with prompts', + command=self.rmenu_copy_with_prompts_handler) + rmenu.tk_popup(event.x_root, event.y_root) + return "break" + + def rmenu_copy_handler(self, event=None): + """Copy selected lines to the clipboard.""" + selected_text = self.text.get('sel.first', 'sel.last') + + self.main_widget.clipboard_clear() + self.main_widget.clipboard_append(selected_text) + + def rmenu_copy_with_prompts_handler(self, event=None): + """Copy selected lines to the clipboard.""" + selected_text = self.text.get('sel.first linestart', + 'sel.last +1line linestart') + lineno_range = range( + get_lineno(self.text, 'sel.first linestart'), + get_lineno(self.text, 'sel.last +1line linestart'), + ) + prompts = [ + self.line_prompts.get(lineno) + for lineno in lineno_range + ] + selected_text_with_prompts = '\n'.join( + line if prompt is None else f'{prompt} {line}' + for prompt, line in zip(prompts, selected_text.splitlines()) + ) + '\n' + + self.main_widget.clipboard_clear() + self.main_widget.clipboard_append(selected_text_with_prompts) + def grid(self): self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0) @@ -436,6 +481,7 @@ def update_sidebar(self): text = self.text text_tagnames = text.tag_names canvas = self.canvas + line_prompts = self.line_prompts = {} canvas.delete(tk.ALL) @@ -456,6 +502,8 @@ def update_sidebar(self): if prompt: canvas.create_text(2, y, anchor=tk.NW, text=prompt, font=self.font, fill=self.colors[0]) + lineno = get_lineno(text, index) + line_prompts[lineno] = prompt index = text.index(f'{index}+1line') def yscroll_event(self, *args, **kwargs): From 117204029a81e6dcc80e957ad1388cbfd92f99c7 Mon Sep 17 00:00:00 2001 From: Tal Einat <532281+taleinat@users.noreply.github.com> Date: Fri, 30 Apr 2021 23:18:00 +0300 Subject: [PATCH 03/13] add tests for shell sidebar context menu actions --- Lib/idlelib/idle_test/test_sidebar.py | 60 +++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/Lib/idlelib/idle_test/test_sidebar.py b/Lib/idlelib/idle_test/test_sidebar.py index 7228d0ee731fa5..26a834ae9719c0 100644 --- a/Lib/idlelib/idle_test/test_sidebar.py +++ b/Lib/idlelib/idle_test/test_sidebar.py @@ -705,6 +705,66 @@ def test_mousewheel(self): yield self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0'))) + @run_in_tk_mainloop + def test_copy(self): + sidebar = self.shell.shell_sidebar + text = self.shell.text + + first_line = get_end_linenumber(text) + + self.do_input(dedent('''\ + if True: + print(1) + + ''')) + yield + + text.tag_add('sel', f'{first_line}.0', 'end-1c') + selected_text = text.get('sel.first', 'sel.last') + self.assertIn('if True:\n', selected_text) + self.assertIn('\n1\n', selected_text) + + sidebar.rmenu_copy_handler(event=None) + self.addCleanup(text.clipboard_clear) + + copied_text = text.clipboard_get() + self.assertEqual(copied_text, selected_text) + + @run_in_tk_mainloop + def test_copy_with_prompts(self): + sidebar = self.shell.shell_sidebar + text = self.shell.text + + first_line = get_end_linenumber(text) + self.do_input(dedent('''\ + if True: + print(1) + + ''')) + yield + + text.tag_add('sel', f'{first_line}.3', 'end-1c') + selected_text = text.get('sel.first', 'sel.last') + self.assertTrue(selected_text.startswith('True:\n')) + + selected_lines_text = text.get('sel.first linestart', 'sel.last') + selected_lines = selected_lines_text.split('\n') + # Expect a block of input, a single output line, and a new prompt + expected_prompts = \ + ['>>>'] + ['...'] * (len(selected_lines) - 3) + [None, '>>>'] + selected_text_with_prompts = '\n'.join( + line if prompt is None else prompt + ' ' + line + for prompt, line in zip(expected_prompts, + selected_lines, + strict=True) + ) + '\n' + + sidebar.rmenu_copy_with_prompts_handler(event=None) + self.addCleanup(text.clipboard_clear) + + copied_text = text.clipboard_get() + self.assertEqual(copied_text, selected_text_with_prompts) + if __name__ == '__main__': unittest.main(verbosity=2) From 9a2fcd9280764ed2cda2d216185da780bb65679c Mon Sep 17 00:00:00 2001 From: Tal Einat <532281+taleinat@users.noreply.github.com> Date: Fri, 30 Apr 2021 23:18:24 +0300 Subject: [PATCH 04/13] cleanup and improved doc-strings --- Lib/idlelib/idle_test/test_sidebar.py | 1 - Lib/idlelib/sidebar.py | 18 ++++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/Lib/idlelib/idle_test/test_sidebar.py b/Lib/idlelib/idle_test/test_sidebar.py index 26a834ae9719c0..477227bf60c8ea 100644 --- a/Lib/idlelib/idle_test/test_sidebar.py +++ b/Lib/idlelib/idle_test/test_sidebar.py @@ -271,7 +271,6 @@ def test_click_selection(self): self.assertEqual(self.get_selection(), ('2.0', '3.0')) - @unittest.skip('test disabled') def simulate_drag(self, start_line, end_line): start_x, start_y = self.get_line_screen_position(start_line) end_x, end_y = self.get_line_screen_position(end_line) diff --git a/Lib/idlelib/sidebar.py b/Lib/idlelib/sidebar.py index f9cd50f11fc6ab..ffbe3163eb58c6 100644 --- a/Lib/idlelib/sidebar.py +++ b/Lib/idlelib/sidebar.py @@ -404,6 +404,9 @@ def delete(self, index1, index2=None): class ShellSidebar(BaseSideBar): """Sidebar for the PyShell window, for prompts etc.""" def __init__(self, editwin): + self.canvas = None + self.line_prompts = {} + super().__init__(editwin) change_delegator = \ @@ -443,15 +446,22 @@ def context_menu_event(self, event): rmenu.tk_popup(event.x_root, event.y_root) return "break" - def rmenu_copy_handler(self, event=None): - """Copy selected lines to the clipboard.""" + def rmenu_copy_handler(self, event): + """Copy selected text to the clipboard.""" selected_text = self.text.get('sel.first', 'sel.last') self.main_widget.clipboard_clear() self.main_widget.clipboard_append(selected_text) - def rmenu_copy_with_prompts_handler(self, event=None): - """Copy selected lines to the clipboard.""" + def rmenu_copy_with_prompts_handler(self, event): + """Copy selected lines to the clipboard, with prompts. + + This makes the copied text useful for doc-tests and interactive + shell code examples. + + This always copies entire lines, even if only part of the first + and/or last lines is selected. + """ selected_text = self.text.get('sel.first linestart', 'sel.last +1line linestart') lineno_range = range( From 823e6a0669ad52fb9148d64b3502e51203426529 Mon Sep 17 00:00:00 2001 From: Tal Einat <532281+taleinat@users.noreply.github.com> Date: Fri, 30 Apr 2021 23:33:36 +0300 Subject: [PATCH 05/13] remove whitespace in otherwise empty line --- Lib/idlelib/idle_test/test_sidebar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/idlelib/idle_test/test_sidebar.py b/Lib/idlelib/idle_test/test_sidebar.py index 477227bf60c8ea..1952b54805f546 100644 --- a/Lib/idlelib/idle_test/test_sidebar.py +++ b/Lib/idlelib/idle_test/test_sidebar.py @@ -714,7 +714,7 @@ def test_copy(self): self.do_input(dedent('''\ if True: print(1) - + ''')) yield From 4f63ef7b061a6f3c843f81e8bd4081b49a1351bf Mon Sep 17 00:00:00 2001 From: Tal Einat <532281+taleinat@users.noreply.github.com> Date: Sat, 1 May 2021 08:59:07 +0300 Subject: [PATCH 06/13] fix: remove unnecessary event args from sidebar context-menu handlers --- Lib/idlelib/idle_test/test_sidebar.py | 4 ++-- Lib/idlelib/sidebar.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/idlelib/idle_test/test_sidebar.py b/Lib/idlelib/idle_test/test_sidebar.py index 1952b54805f546..f2437a9d978f4c 100644 --- a/Lib/idlelib/idle_test/test_sidebar.py +++ b/Lib/idlelib/idle_test/test_sidebar.py @@ -723,7 +723,7 @@ def test_copy(self): self.assertIn('if True:\n', selected_text) self.assertIn('\n1\n', selected_text) - sidebar.rmenu_copy_handler(event=None) + sidebar.rmenu_copy_handler() self.addCleanup(text.clipboard_clear) copied_text = text.clipboard_get() @@ -758,7 +758,7 @@ def test_copy_with_prompts(self): strict=True) ) + '\n' - sidebar.rmenu_copy_with_prompts_handler(event=None) + sidebar.rmenu_copy_with_prompts_handler() self.addCleanup(text.clipboard_clear) copied_text = text.clipboard_get() diff --git a/Lib/idlelib/sidebar.py b/Lib/idlelib/sidebar.py index ffbe3163eb58c6..c898c19d403c98 100644 --- a/Lib/idlelib/sidebar.py +++ b/Lib/idlelib/sidebar.py @@ -446,14 +446,14 @@ def context_menu_event(self, event): rmenu.tk_popup(event.x_root, event.y_root) return "break" - def rmenu_copy_handler(self, event): + def rmenu_copy_handler(self): """Copy selected text to the clipboard.""" selected_text = self.text.get('sel.first', 'sel.last') self.main_widget.clipboard_clear() self.main_widget.clipboard_append(selected_text) - def rmenu_copy_with_prompts_handler(self, event): + def rmenu_copy_with_prompts_handler(self): """Copy selected lines to the clipboard, with prompts. This makes the copied text useful for doc-tests and interactive From ad28ec180aa519fc1348ef10bb5332d8a4e8f2fb Mon Sep 17 00:00:00 2001 From: Tal Einat <532281+taleinat@users.noreply.github.com> Date: Sat, 1 May 2021 09:51:03 +0300 Subject: [PATCH 07/13] add "Copy only code" shell sidebar context-menu option --- Lib/idlelib/idle_test/test_sidebar.py | 27 ++++++++++++++++++++++++++- Lib/idlelib/sidebar.py | 16 ++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Lib/idlelib/idle_test/test_sidebar.py b/Lib/idlelib/idle_test/test_sidebar.py index f2437a9d978f4c..4601d69c2ff942 100644 --- a/Lib/idlelib/idle_test/test_sidebar.py +++ b/Lib/idlelib/idle_test/test_sidebar.py @@ -720,7 +720,7 @@ def test_copy(self): text.tag_add('sel', f'{first_line}.0', 'end-1c') selected_text = text.get('sel.first', 'sel.last') - self.assertIn('if True:\n', selected_text) + self.assertTrue(selected_text.startswith('if True:\n')) self.assertIn('\n1\n', selected_text) sidebar.rmenu_copy_handler() @@ -764,6 +764,31 @@ def test_copy_with_prompts(self): copied_text = text.clipboard_get() self.assertEqual(copied_text, selected_text_with_prompts) + @run_in_tk_mainloop + def test_copy_only_code(self): + sidebar = self.shell.shell_sidebar + text = self.shell.text + + first_line = get_end_linenumber(text) + + self.do_input(dedent('''\ + if True: + print(1) + + ''')) + yield + + text.tag_add('sel', f'{first_line}.0', 'end-1c') + selected_text = text.get('sel.first', 'sel.last') + self.assertTrue(selected_text.startswith('if True:\n')) + self.assertIn('\n1\n', selected_text) + + sidebar.rmenu_copy_only_code_handler() + self.addCleanup(text.clipboard_clear) + + copied_text = text.clipboard_get() + self.assertRegex(copied_text.strip(), r'^if True:\n[ \t]+print\(1\)$') + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/Lib/idlelib/sidebar.py b/Lib/idlelib/sidebar.py index c898c19d403c98..d4f951d70cd88c 100644 --- a/Lib/idlelib/sidebar.py +++ b/Lib/idlelib/sidebar.py @@ -443,6 +443,8 @@ def context_menu_event(self, event): rmenu.add_command(label='Copy', command=self.rmenu_copy_handler) rmenu.add_command(label='Copy with prompts', command=self.rmenu_copy_with_prompts_handler) + rmenu.add_command(label='Copy only code', + command=self.rmenu_copy_only_code_handler) rmenu.tk_popup(event.x_root, event.y_root) return "break" @@ -480,6 +482,20 @@ def rmenu_copy_with_prompts_handler(self): self.main_widget.clipboard_clear() self.main_widget.clipboard_append(selected_text_with_prompts) + def rmenu_copy_only_code_handler(self): + """Copy only the code from the selected text to the clipboard.""" + text = self.text + + # Find all text with the 'stdin' tag in the selected range + code_pieces = [] + index = text.index('sel.first') + while tag_range := text.tag_nextrange('stdin', index, 'sel.last'): + index = tag_range[1] + code_pieces.append(text.get(*tag_range)) + + self.main_widget.clipboard_clear() + self.main_widget.clipboard_append('\n'.join(code_pieces)) + def grid(self): self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0) From d790175e1df5d1e2e26a324fd59389e9ecd96e86 Mon Sep 17 00:00:00 2001 From: Tal Einat <532281+taleinat@users.noreply.github.com> Date: Sat, 1 May 2021 11:16:09 +0300 Subject: [PATCH 08/13] disable shell sidebar context-menu copying when there is no selection --- Lib/idlelib/sidebar.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Lib/idlelib/sidebar.py b/Lib/idlelib/sidebar.py index d4f951d70cd88c..2061c053b2d8d1 100644 --- a/Lib/idlelib/sidebar.py +++ b/Lib/idlelib/sidebar.py @@ -14,7 +14,8 @@ def get_lineno(text, index): """Return the line number of an index in a Tk text widget.""" - return int(float(text.index(index))) + text_index = text.index(index) + return int(float(text_index)) if text_index else None def get_end_linenumber(text): @@ -440,11 +441,15 @@ def bind_events(self): def context_menu_event(self, event): rmenu = tk.Menu(self.main_widget, tearoff=0) - rmenu.add_command(label='Copy', command=self.rmenu_copy_handler) + has_selection = bool(self.text.tag_nextrange('sel', '1.0')) + rmenu.add_command(label='Copy', command=self.rmenu_copy_handler, + state='normal' if has_selection else 'disabled') rmenu.add_command(label='Copy with prompts', - command=self.rmenu_copy_with_prompts_handler) + command=self.rmenu_copy_with_prompts_handler, + state='normal' if has_selection else 'disabled') rmenu.add_command(label='Copy only code', - command=self.rmenu_copy_only_code_handler) + command=self.rmenu_copy_only_code_handler, + state='normal' if has_selection else 'disabled') rmenu.tk_popup(event.x_root, event.y_root) return "break" From b20c959f183940793c88cbdbb4078f08e0c3b21b Mon Sep 17 00:00:00 2001 From: Tal Einat <532281+taleinat@users.noreply.github.com> Date: Sat, 1 May 2021 11:45:40 +0300 Subject: [PATCH 09/13] fix recall of auto-completed input --- Lib/idlelib/autocomplete.py | 5 +++-- Lib/idlelib/autocomplete_w.py | 7 +++++-- Lib/idlelib/editor.py | 2 +- Lib/idlelib/idle_test/test_autocomplete_w.py | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Lib/idlelib/autocomplete.py b/Lib/idlelib/autocomplete.py index e1e9e17311eda1..bb7ee035c4fefb 100644 --- a/Lib/idlelib/autocomplete.py +++ b/Lib/idlelib/autocomplete.py @@ -31,10 +31,11 @@ class AutoComplete: - def __init__(self, editwin=None): + def __init__(self, editwin=None, tags=None): self.editwin = editwin if editwin is not None: # not in subprocess or no-gui test self.text = editwin.text + self.tags = tags self.autocompletewindow = None # id of delayed call, and the index of the text insert when # the delayed call was issued. If _delayed_completion_id is @@ -48,7 +49,7 @@ def reload(cls): "extensions", "AutoComplete", "popupwait", type="int", default=0) def _make_autocomplete_window(self): # Makes mocking easier. - return autocomplete_w.AutoCompleteWindow(self.text) + return autocomplete_w.AutoCompleteWindow(self.text, tags=self.tags) def _remove_autocomplete_window(self, event=None): if self.autocompletewindow: diff --git a/Lib/idlelib/autocomplete_w.py b/Lib/idlelib/autocomplete_w.py index fe7a6be83d586b..d3d1e6982bfb2e 100644 --- a/Lib/idlelib/autocomplete_w.py +++ b/Lib/idlelib/autocomplete_w.py @@ -26,9 +26,11 @@ class AutoCompleteWindow: - def __init__(self, widget): + def __init__(self, widget, tags): # The widget (Text) on which we place the AutoCompleteWindow self.widget = widget + # Tags to mark inserted text with + self.tags = tags # The widgets we create self.autocompletewindow = self.listbox = self.scrollbar = None # The default foreground and background of a selection. Saved because @@ -69,7 +71,8 @@ def _change_start(self, newstart): "%s+%dc" % (self.startindex, len(self.start))) if i < len(newstart): self.widget.insert("%s+%dc" % (self.startindex, i), - newstart[i:]) + newstart[i:], + self.tags) self.start = newstart def _binary_search(self, s): diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py index 8b544407da2e0d..fcc8a3f08ccfe3 100644 --- a/Lib/idlelib/editor.py +++ b/Lib/idlelib/editor.py @@ -311,7 +311,7 @@ def __init__(self, flist=None, filename=None, key=None, root=None): # Former extension bindings depends on frame.text being packed # (called from self.ResetColorizer()). - autocomplete = self.AutoComplete(self) + autocomplete = self.AutoComplete(self, self.user_input_insert_tags) text.bind("<>", autocomplete.autocomplete_event) text.bind("<>", autocomplete.try_open_completions_event) diff --git a/Lib/idlelib/idle_test/test_autocomplete_w.py b/Lib/idlelib/idle_test/test_autocomplete_w.py index b1bdc6c7c6e1a5..a59a375c90fd80 100644 --- a/Lib/idlelib/idle_test/test_autocomplete_w.py +++ b/Lib/idlelib/idle_test/test_autocomplete_w.py @@ -15,7 +15,7 @@ def setUpClass(cls): cls.root = Tk() cls.root.withdraw() cls.text = Text(cls.root) - cls.acw = acw.AutoCompleteWindow(cls.text) + cls.acw = acw.AutoCompleteWindow(cls.text, tags=None) @classmethod def tearDownClass(cls): From 7d1c5f539ef033ccb16a8061bb5803d21ca1ac47 Mon Sep 17 00:00:00 2001 From: Tal Einat <532281+taleinat@users.noreply.github.com> Date: Sat, 1 May 2021 12:51:52 +0300 Subject: [PATCH 10/13] add "Copy with prompts" and "Copy only code" to shell's context menu too --- Lib/idlelib/pyshell.py | 68 ++++++++++++++++++++++++++++++++++++++++++ Lib/idlelib/sidebar.py | 57 ++++------------------------------- 2 files changed, 74 insertions(+), 51 deletions(-) diff --git a/Lib/idlelib/pyshell.py b/Lib/idlelib/pyshell.py index 447e9ec3e47563..4ee2acf74711db 100755 --- a/Lib/idlelib/pyshell.py +++ b/Lib/idlelib/pyshell.py @@ -33,6 +33,7 @@ raise SystemExit(1) from code import InteractiveInterpreter +import itertools import linecache import os import os.path @@ -865,6 +866,16 @@ class PyShell(OutputWindow): rmenu_specs = OutputWindow.rmenu_specs + [ ("Squeeze", "<>"), ] + _idx = 1 + len(list(itertools.takewhile( + lambda rmenu_item: rmenu_item[0] != "Copy", rmenu_specs) + )) + rmenu_specs.insert(_idx, ("Copy with prompts", + "<>", + "rmenu_check_copy")) + rmenu_specs.insert(_idx + 1, ("Copy only code", + "<>", + "rmenu_check_copy")) + del _idx allow_line_numbers = False user_input_insert_tags = "stdin" @@ -906,6 +917,8 @@ def __init__(self, flist=None): text.bind("<>", self.open_stack_viewer) text.bind("<>", self.toggle_debugger) text.bind("<>", self.toggle_jit_stack_viewer) + text.bind("<>", self.copy_with_prompts_callback) + text.bind("<>", self.copy_only_code_callback) if use_subprocess: text.bind("<>", self.view_restart_mark) text.bind("<>", self.restart_shell) @@ -979,6 +992,61 @@ def replace_event(self, event): def get_standard_extension_names(self): return idleConf.GetExtensions(shell_only=True) + def copy_with_prompts_callback(self, event=None): + """Copy selected lines to the clipboard, with prompts. + + This makes the copied text useful for doc-tests and interactive + shell code examples. + + This always copies entire lines, even if only part of the first + and/or last lines is selected. + """ + text = self.text + + selection_indexes = ( + self.text.index("sel.first linestart"), + self.text.index("sel.last +1line linestart"), + ) + if selection_indexes[0] is None: + # There is no selection, so do nothing. + return + + selected_text = self.text.get(*selection_indexes) + selection_lineno_range = range( + int(float(selection_indexes[0])), + int(float(selection_indexes[1])) + ) + prompts = [ + self.shell_sidebar.line_prompts.get(lineno) + for lineno in selection_lineno_range + ] + selected_text_with_prompts = "\n".join( + line if prompt is None else f"{prompt} {line}" + for prompt, line in zip(prompts, selected_text.splitlines()) + ) + "\n" + + text.clipboard_clear() + text.clipboard_append(selected_text_with_prompts) + + def copy_only_code_callback(self, event=None): + """Copy only the code from the selected text to the clipboard.""" + text = self.text + + if not text.tag_ranges("sel"): + # There is no selection, so do nothing. + return None + + # Find all text with the "stdin" tag in the selected range + code_pieces = [] + index = text.index("sel.first") + while tag_range := text.tag_nextrange("stdin", index, "sel.last"): + index = tag_range[1] + code_pieces.append(text.get(*tag_range)) + + text.clipboard_clear() + text.clipboard_append("\n".join(code_pieces)) + + reading = False executing = False canceled = False diff --git a/Lib/idlelib/sidebar.py b/Lib/idlelib/sidebar.py index 2061c053b2d8d1..63e3e78e1ea88a 100644 --- a/Lib/idlelib/sidebar.py +++ b/Lib/idlelib/sidebar.py @@ -442,65 +442,20 @@ def bind_events(self): def context_menu_event(self, event): rmenu = tk.Menu(self.main_widget, tearoff=0) has_selection = bool(self.text.tag_nextrange('sel', '1.0')) - rmenu.add_command(label='Copy', command=self.rmenu_copy_handler, + def mkcmd(eventname): + return lambda: self.text.event_generate(eventname) + rmenu.add_command(label='Copy', + command=mkcmd('<>'), state='normal' if has_selection else 'disabled') rmenu.add_command(label='Copy with prompts', - command=self.rmenu_copy_with_prompts_handler, + command=mkcmd('<>'), state='normal' if has_selection else 'disabled') rmenu.add_command(label='Copy only code', - command=self.rmenu_copy_only_code_handler, + command=mkcmd('<>'), state='normal' if has_selection else 'disabled') rmenu.tk_popup(event.x_root, event.y_root) return "break" - def rmenu_copy_handler(self): - """Copy selected text to the clipboard.""" - selected_text = self.text.get('sel.first', 'sel.last') - - self.main_widget.clipboard_clear() - self.main_widget.clipboard_append(selected_text) - - def rmenu_copy_with_prompts_handler(self): - """Copy selected lines to the clipboard, with prompts. - - This makes the copied text useful for doc-tests and interactive - shell code examples. - - This always copies entire lines, even if only part of the first - and/or last lines is selected. - """ - selected_text = self.text.get('sel.first linestart', - 'sel.last +1line linestart') - lineno_range = range( - get_lineno(self.text, 'sel.first linestart'), - get_lineno(self.text, 'sel.last +1line linestart'), - ) - prompts = [ - self.line_prompts.get(lineno) - for lineno in lineno_range - ] - selected_text_with_prompts = '\n'.join( - line if prompt is None else f'{prompt} {line}' - for prompt, line in zip(prompts, selected_text.splitlines()) - ) + '\n' - - self.main_widget.clipboard_clear() - self.main_widget.clipboard_append(selected_text_with_prompts) - - def rmenu_copy_only_code_handler(self): - """Copy only the code from the selected text to the clipboard.""" - text = self.text - - # Find all text with the 'stdin' tag in the selected range - code_pieces = [] - index = text.index('sel.first') - while tag_range := text.tag_nextrange('stdin', index, 'sel.last'): - index = tag_range[1] - code_pieces.append(text.get(*tag_range)) - - self.main_widget.clipboard_clear() - self.main_widget.clipboard_append('\n'.join(code_pieces)) - def grid(self): self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0) From 90b0ef8f816dd28182f51f0598860054d1478433 Mon Sep 17 00:00:00 2001 From: Tal Einat <532281+taleinat@users.noreply.github.com> Date: Sat, 1 May 2021 12:55:20 +0300 Subject: [PATCH 11/13] fix sidebar tests --- Lib/idlelib/idle_test/test_sidebar.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/idlelib/idle_test/test_sidebar.py b/Lib/idlelib/idle_test/test_sidebar.py index 1210fd5c866640..e85c312a7887bb 100644 --- a/Lib/idlelib/idle_test/test_sidebar.py +++ b/Lib/idlelib/idle_test/test_sidebar.py @@ -722,7 +722,7 @@ def test_copy(self): self.assertTrue(selected_text.startswith('if True:\n')) self.assertIn('\n1\n', selected_text) - sidebar.rmenu_copy_handler() + text.event_generate('<>') self.addCleanup(text.clipboard_clear) copied_text = text.clipboard_get() @@ -757,7 +757,7 @@ def test_copy_with_prompts(self): strict=True) ) + '\n' - sidebar.rmenu_copy_with_prompts_handler() + text.event_generate('<>') self.addCleanup(text.clipboard_clear) copied_text = text.clipboard_get() @@ -782,7 +782,7 @@ def test_copy_only_code(self): self.assertTrue(selected_text.startswith('if True:\n')) self.assertIn('\n1\n', selected_text) - sidebar.rmenu_copy_only_code_handler() + text.event_generate('<>') self.addCleanup(text.clipboard_clear) copied_text = text.clipboard_get() From 3c711c0124cc92c750cfa908e6fb6879c8b9e217 Mon Sep 17 00:00:00 2001 From: Tal Einat <532281+taleinat@users.noreply.github.com> Date: Sat, 1 May 2021 21:16:02 +0300 Subject: [PATCH 12/13] remove "copy-only-code" feature --- Lib/idlelib/idle_test/test_sidebar.py | 25 ------------------------- Lib/idlelib/pyshell.py | 23 ----------------------- Lib/idlelib/sidebar.py | 3 --- 3 files changed, 51 deletions(-) diff --git a/Lib/idlelib/idle_test/test_sidebar.py b/Lib/idlelib/idle_test/test_sidebar.py index e85c312a7887bb..43e8137d7079c9 100644 --- a/Lib/idlelib/idle_test/test_sidebar.py +++ b/Lib/idlelib/idle_test/test_sidebar.py @@ -763,31 +763,6 @@ def test_copy_with_prompts(self): copied_text = text.clipboard_get() self.assertEqual(copied_text, selected_text_with_prompts) - @run_in_tk_mainloop - def test_copy_only_code(self): - sidebar = self.shell.shell_sidebar - text = self.shell.text - - first_line = get_end_linenumber(text) - - self.do_input(dedent('''\ - if True: - print(1) - - ''')) - yield - - text.tag_add('sel', f'{first_line}.0', 'end-1c') - selected_text = text.get('sel.first', 'sel.last') - self.assertTrue(selected_text.startswith('if True:\n')) - self.assertIn('\n1\n', selected_text) - - text.event_generate('<>') - self.addCleanup(text.clipboard_clear) - - copied_text = text.clipboard_get() - self.assertRegex(copied_text.strip(), r'^if True:\n[ \t]+print\(1\)$') - if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/Lib/idlelib/pyshell.py b/Lib/idlelib/pyshell.py index 4ee2acf74711db..4e7440038ac997 100755 --- a/Lib/idlelib/pyshell.py +++ b/Lib/idlelib/pyshell.py @@ -872,9 +872,6 @@ class PyShell(OutputWindow): rmenu_specs.insert(_idx, ("Copy with prompts", "<>", "rmenu_check_copy")) - rmenu_specs.insert(_idx + 1, ("Copy only code", - "<>", - "rmenu_check_copy")) del _idx allow_line_numbers = False @@ -918,7 +915,6 @@ def __init__(self, flist=None): text.bind("<>", self.toggle_debugger) text.bind("<>", self.toggle_jit_stack_viewer) text.bind("<>", self.copy_with_prompts_callback) - text.bind("<>", self.copy_only_code_callback) if use_subprocess: text.bind("<>", self.view_restart_mark) text.bind("<>", self.restart_shell) @@ -1028,25 +1024,6 @@ def copy_with_prompts_callback(self, event=None): text.clipboard_clear() text.clipboard_append(selected_text_with_prompts) - def copy_only_code_callback(self, event=None): - """Copy only the code from the selected text to the clipboard.""" - text = self.text - - if not text.tag_ranges("sel"): - # There is no selection, so do nothing. - return None - - # Find all text with the "stdin" tag in the selected range - code_pieces = [] - index = text.index("sel.first") - while tag_range := text.tag_nextrange("stdin", index, "sel.last"): - index = tag_range[1] - code_pieces.append(text.get(*tag_range)) - - text.clipboard_clear() - text.clipboard_append("\n".join(code_pieces)) - - reading = False executing = False canceled = False diff --git a/Lib/idlelib/sidebar.py b/Lib/idlelib/sidebar.py index 63e3e78e1ea88a..018c368f421c6c 100644 --- a/Lib/idlelib/sidebar.py +++ b/Lib/idlelib/sidebar.py @@ -450,9 +450,6 @@ def mkcmd(eventname): rmenu.add_command(label='Copy with prompts', command=mkcmd('<>'), state='normal' if has_selection else 'disabled') - rmenu.add_command(label='Copy only code', - command=mkcmd('<>'), - state='normal' if has_selection else 'disabled') rmenu.tk_popup(event.x_root, event.y_root) return "break" From a1e6bf1218239de8dcdb57f581267ccc4a1b6940 Mon Sep 17 00:00:00 2001 From: Terry Jan Reedy Date: Sun, 2 May 2021 21:42:48 -0400 Subject: [PATCH 13/13] News and What's New items. --- Doc/whatsnew/3.10.rst | 26 +++++++++++++++++++ Lib/idlelib/NEWS.txt | 15 ++++++++--- .../2021-05-02-20-25-53.bpo-37903.VQ6VTU.rst | 4 +++ 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/IDLE/2021-05-02-20-25-53.bpo-37903.VQ6VTU.rst diff --git a/Doc/whatsnew/3.10.rst b/Doc/whatsnew/3.10.rst index 223ab65cfc3118..eb452b07f55f61 100644 --- a/Doc/whatsnew/3.10.rst +++ b/Doc/whatsnew/3.10.rst @@ -994,6 +994,32 @@ hmac The hmac module now uses OpenSSL's HMAC implementation internally. (Contributed by Christian Heimes in :issue:`40645`.) +IDLE and idlelib +---------------- + +Make IDLE invoke :func:`sys.excepthook` (when started without '-n'). +User hooks were previously ignored. (Patch by Ken Hilton in +:issue:`43008`.) + +This change was backported to a 3.9 maintenance release. + +Add a Shell sidebar. Move the primary prompt ('>>>') to the sidebar. +Add secondary prompts ('...') to the sidebar. Left click and optional +drag selects one or more lines of text, as with the editor +line number sidebar. Right click after selecting text lines displays +a context menu with 'copy with prompts'. This zips together prompts +from the sidebar with lines from the selected text. This option also +appears on the context menu for the text. (Contributed by Tal Einat +in :issue:`37903`.) + +Use spaces instead of tabs to indent interactive code. This makes +interactive code entries 'look right'. Making this feasible was a +major motivation for adding the shell sidebar. Contributed by +Terry Jan Reedy in :issue:`37892`.) + +We expect to backport these shell changes to a future 3.9 maintenance +release. + importlib.metadata ------------------ diff --git a/Lib/idlelib/NEWS.txt b/Lib/idlelib/NEWS.txt index 83afe3ecac908c..ed1142653d9534 100644 --- a/Lib/idlelib/NEWS.txt +++ b/Lib/idlelib/NEWS.txt @@ -4,7 +4,15 @@ Released on 2021-10-04? ========================= -bpo-37892: Change Shell input indents from tabs to spaces. +bpo-37903: Add mouse actions to the shell sidebar. Left click and +optional drag selects one or more lines of text, as with the +editor line number sidebar. Right click after selecting text lines +displays a context menu with 'copy with prompts'. This zips together +prompts from the sidebar with lines from the selected text. This option +also appears on the context menu for the text. + +bpo-37892: Change Shell input indents from tabs to spaces. Shell input +now 'looks right'. Making this feasible motivated the shell sidebar. bpo-37903: Move the Shell input prompt to a side bar. @@ -19,7 +27,8 @@ bpo-23544: Disable Debug=>Stack Viewer when user code is running or Debugger is active, to prevent hang or crash. Patch by Zackery Spytz. bpo-43008: Make IDLE invoke :func:`sys.excepthook` in normal, -2-process mode. Patch by Ken Hilton. +2-process mode. User hooks were previously ignored. +Patch by Ken Hilton. bpo-33065: Fix problem debugging user classes with __repr__ method. @@ -32,7 +41,7 @@ installers built on macOS 11. bpo-42426: Fix reporting offset of the RE error in searchengine. -bpo-42416: Get docstrings for IDLE calltips more often +bpo-42416: Display docstrings in IDLE calltips in more cases, by using inspect.getdoc. bpo-33987: Mostly finish using ttk widgets, mainly for editor, diff --git a/Misc/NEWS.d/next/IDLE/2021-05-02-20-25-53.bpo-37903.VQ6VTU.rst b/Misc/NEWS.d/next/IDLE/2021-05-02-20-25-53.bpo-37903.VQ6VTU.rst new file mode 100644 index 00000000000000..28b11e60f0fb3e --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2021-05-02-20-25-53.bpo-37903.VQ6VTU.rst @@ -0,0 +1,4 @@ +Add mouse actions to the shell sidebar. Left click and optional drag +selects one or more lines, as with the editor line number sidebar. Right +click after selecting raises a context menu with 'copy with prompts'. This +zips together prompts from the sidebar with lines from the selected text.