summaryrefslogtreecommitdiffstats
path: root/Lib
diff options
context:
space:
mode:
authorTal Einat <532281+taleinat@users.noreply.github.com>2021-05-03 02:27:38 (GMT)
committerGitHub <noreply@github.com>2021-05-03 02:27:38 (GMT)
commitb43cc31a270d0dacbc69e35d6c6fbdb5edd7e711 (patch)
treef656c691cf80d2b927037808ea51d4f75ae20a16 /Lib
parent90d523910a61290597b4599f17363b532f0a4411 (diff)
downloadcpython-b43cc31a270d0dacbc69e35d6c6fbdb5edd7e711.zip
cpython-b43cc31a270d0dacbc69e35d6c6fbdb5edd7e711.tar.gz
cpython-b43cc31a270d0dacbc69e35d6c6fbdb5edd7e711.tar.bz2
bpo-37903: IDLE: add shell sidebar mouse interactions (GH-25708)
Left click and drag to select lines. With selection, right click for context menu with copy and copy-with-prompts. Also add copy-with-prompts to the text-box context menu. Co-authored-by: Terry Jan Reedy <tjreedy@udel.edu>
Diffstat (limited to 'Lib')
-rw-r--r--Lib/idlelib/NEWS.txt15
-rw-r--r--Lib/idlelib/autocomplete.py5
-rw-r--r--Lib/idlelib/autocomplete_w.py7
-rw-r--r--Lib/idlelib/editor.py2
-rw-r--r--Lib/idlelib/idle_test/test_autocomplete_w.py2
-rw-r--r--Lib/idlelib/idle_test/test_sidebar.py61
-rwxr-xr-xLib/idlelib/pyshell.py45
-rw-r--r--Lib/idlelib/sidebar.py411
8 files changed, 336 insertions, 212 deletions
diff --git a/Lib/idlelib/NEWS.txt b/Lib/idlelib/NEWS.txt
index 83afe3e..ed11426 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/Lib/idlelib/autocomplete.py b/Lib/idlelib/autocomplete.py
index e1e9e17..bb7ee03 100644
--- a/Lib/idlelib/autocomplete.py
+++ b/Lib/idlelib/autocomplete.py
@@ -31,10 +31,11 @@ TRIGGERS = f".{SEPS}"
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 @@ class AutoComplete:
"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 fe7a6be..d3d1e69 100644
--- a/Lib/idlelib/autocomplete_w.py
+++ b/Lib/idlelib/autocomplete_w.py
@@ -26,9 +26,11 @@ DOUBLECLICK_SEQUENCE = "<B1-Double-ButtonRelease>"
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 @@ class AutoCompleteWindow:
"%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 8b54440..fcc8a3f 100644
--- a/Lib/idlelib/editor.py
+++ b/Lib/idlelib/editor.py
@@ -311,7 +311,7 @@ class EditorWindow:
# 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.autocomplete_event)
text.bind("<<try-open-completions>>",
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 b1bdc6c..a59a375 100644
--- a/Lib/idlelib/idle_test/test_autocomplete_w.py
+++ b/Lib/idlelib/idle_test/test_autocomplete_w.py
@@ -15,7 +15,7 @@ class AutoCompleteWindowTest(unittest.TestCase):
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):
diff --git a/Lib/idlelib/idle_test/test_sidebar.py b/Lib/idlelib/idle_test/test_sidebar.py
index 0497f6d..43e8137 100644
--- a/Lib/idlelib/idle_test/test_sidebar.py
+++ b/Lib/idlelib/idle_test/test_sidebar.py
@@ -270,7 +270,6 @@ class LineNumbersTest(unittest.TestCase):
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)
@@ -704,6 +703,66 @@ class ShellSidebarTest(unittest.TestCase):
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.assertTrue(selected_text.startswith('if True:\n'))
+ self.assertIn('\n1\n', selected_text)
+
+ text.event_generate('<<copy>>')
+ 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'
+
+ text.event_generate('<<copy-with-prompts>>')
+ 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)
diff --git a/Lib/idlelib/pyshell.py b/Lib/idlelib/pyshell.py
index 447e9ec..4e74400 100755
--- a/Lib/idlelib/pyshell.py
+++ b/Lib/idlelib/pyshell.py
@@ -33,6 +33,7 @@ if TkVersion < 8.5:
raise SystemExit(1)
from code import InteractiveInterpreter
+import itertools
import linecache
import os
import os.path
@@ -865,6 +866,13 @@ class PyShell(OutputWindow):
rmenu_specs = OutputWindow.rmenu_specs + [
("Squeeze", "<<squeeze-current-text>>"),
]
+ _idx = 1 + len(list(itertools.takewhile(
+ lambda rmenu_item: rmenu_item[0] != "Copy", rmenu_specs)
+ ))
+ rmenu_specs.insert(_idx, ("Copy with prompts",
+ "<<copy-with-prompts>>",
+ "rmenu_check_copy"))
+ del _idx
allow_line_numbers = False
user_input_insert_tags = "stdin"
@@ -906,6 +914,7 @@ class PyShell(OutputWindow):
text.bind("<<open-stack-viewer>>", self.open_stack_viewer)
text.bind("<<toggle-debugger>>", self.toggle_debugger)
text.bind("<<toggle-jit-stack-viewer>>", self.toggle_jit_stack_viewer)
+ text.bind("<<copy-with-prompts>>", self.copy_with_prompts_callback)
if use_subprocess:
text.bind("<<view-restart>>", self.view_restart_mark)
text.bind("<<restart-shell>>", self.restart_shell)
@@ -979,6 +988,42 @@ class PyShell(OutputWindow):
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)
+
reading = False
executing = False
canceled = False
diff --git a/Lib/idlelib/sidebar.py b/Lib/idlelib/sidebar.py
index a947961..018c368 100644
--- a/Lib/idlelib/sidebar.py
+++ b/Lib/idlelib/sidebar.py
@@ -9,11 +9,13 @@ import tkinter as tk
from tkinter.font import Font
from idlelib.config import idleConf
from idlelib.delegator import Delegator
+from idlelib import macosx
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):
@@ -70,56 +72,52 @@ class BaseSideBar:
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 +136,17 @@ class BaseSideBar:
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('<FocusIn>', self.redirect_focusin_event)
+ self.main_widget.bind('<FocusIn>', 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('<MouseWheel>', self.redirect_mousewheel_event)
+ self.main_widget.bind('<MouseWheel>', self.redirect_mousewheel_event)
# Redirect mouse button events to the main editor text widget,
# except for the left mouse button (1).
@@ -197,7 +155,7 @@ class LineNumbers(BaseSideBar):
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'<Button-{button}>',
@@ -214,83 +172,162 @@ class LineNumbers(BaseSideBar):
bind_mouse_event(event_name,
target_event_name=f'<Button-{button}>')
- # 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 <Button-1> to allow selecting a range of rows
+ # by dragging. It is cleared upon <ButtonRelease-1>.
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 <B1-Leave> and is continuously updated
+ # upon <B1-Motion>, until <B1-Enter> 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 <B1-Enter>, 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('<Button-1>', 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('<Button-1>', 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('<ButtonRelease-1>', b1_mouseup_handler)
+ self.text.event_generate('<ButtonRelease-1>', x=0, y=event.y)
+ self.main_widget.bind('<ButtonRelease-1>', 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")
-
- # 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-Motion>', 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-Motion>', 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>>', 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>', 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>', 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 +356,10 @@ class LineNumbers(BaseSideBar):
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 +402,16 @@ class WrappedLineHeightChangeDelegator(Delegator):
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 = None
+ self.line_prompts = {}
- 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 +420,41 @@ class ShellSidebar:
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 bind_events(self):
+ super().bind_events()
+
+ self.main_widget.bind(
+ # AquaTk defines <2> as the right button, not <3>.
+ "<Button-2>" if macosx.isAquaTk() else "<Button-3>",
+ self.context_menu_event,
+ )
+
+ def context_menu_event(self, event):
+ rmenu = tk.Menu(self.main_widget, tearoff=0)
+ has_selection = bool(self.text.tag_nextrange('sel', '1.0'))
+ def mkcmd(eventname):
+ return lambda: self.text.event_generate(eventname)
+ rmenu.add_command(label='Copy',
+ command=mkcmd('<<copy>>'),
+ state='normal' if has_selection else 'disabled')
+ rmenu.add_command(label='Copy with prompts',
+ command=mkcmd('<<copy-with-prompts>>'),
+ state='normal' if has_selection else 'disabled')
+ rmenu.tk_popup(event.x_root, event.y_root)
+ return "break"
+
+ 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:
@@ -403,6 +464,7 @@ class ShellSidebar:
text = self.text
text_tagnames = text.tag_names
canvas = self.canvas
+ line_prompts = self.line_prompts = {}
canvas.delete(tk.ALL)
@@ -423,6 +485,8 @@ class ShellSidebar:
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):
@@ -430,7 +494,6 @@ class ShellSidebar:
The scroll bar is also updated.
"""
- self.editwin.vbar.set(*args)
self.change_callback()
return 'break'
@@ -440,9 +503,6 @@ class ShellSidebar:
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 +510,12 @@ class ShellSidebar:
"""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('<MouseWheel>',
- 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('<FocusIn>', 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('<MouseWheel>', 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'<Button-{button}>',
- f'<ButtonRelease-{button}>',
- f'<B{button}-Motion>',
- ):
- 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'<Double-Button-{button}>',
- f'<Triple-Button-{button}>',
- ):
- bind_mouse_event(event_name,
- target_event_name=f'<Button-{button}>')
-
def _linenumbers_drag_scrolling(parent): # htest #
from idlelib.idle_test.test_sidebar import Dummy_editwin