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>
This commit is contained in:
Tal Einat 2021-05-03 05:27:38 +03:00 committed by GitHub
parent 90d523910a
commit b43cc31a27
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 384 additions and 230 deletions

View file

@ -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,6 +136,132 @@ class BaseSideBar:
x=0, y=event.y, delta=event.delta)
return 'break'
def bind_events(self):
self.text['yscrollcommand'] = self.redirect_yscroll_event
# Ensure focus is always redirected to the main editor text widget.
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.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).
#
# 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.main_widget.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}>')
# 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
# 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"{a}.0", f"{b+1}.0")
self.text.mark_set("insert",
f"{lineno if lineno == a else lineno + 1}.0")
def b1_mousedown_handler(event):
nonlocal start_line
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
start_line = None
last_y = None
self.text.event_generate('<ButtonRelease-1>', x=0, y=event.y)
self.main_widget.bind('<ButtonRelease-1>', b1_mouseup_handler)
def b1_drag_handler(event):
nonlocal last_y
if last_y is None: # i.e. if not currently dragging
return
last_y = event.y
drag_update_selection_and_insert_mark(event.y)
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
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.
@ -160,137 +284,50 @@ class EndLineDelegator(Delegator):
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)
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(filter=end_line_delegator,
self.editwin.per.insertfilterafter(end_line_delegator,
after=self.editwin.undo)
def bind_events(self):
# Ensure focus is always redirected to the main editor text widget.
self.sidebar_text.bind('<FocusIn>', self.redirect_focusin_event)
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)
# 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.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)
# 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.sidebar_text.bind(event_name, handler)
end = get_end_linenumber(self.text)
self.update_sidebar_text(end)
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)
return self.sidebar_text
# 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 grid(self):
self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW)
# This is set by b1_mousedown_handler() and read by
# drag_update_selection_and_insert_mark(), to know where dragging
# began.
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}")))
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")
# remember this line in case this is the beginning of dragging
nonlocal start_line
start_line = lineno
self.sidebar_text.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)
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):
nonlocal last_y
nonlocal last_yview
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.
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)
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,16 +420,42 @@ class ShellSidebar:
d = d.delegate
self.editwin.per.insertfilterafter(change_delegator, after=d)
self.text['yscrollcommand'] = self.yscroll_event
self.is_shown = False
self.update_font()
self.update_colors()
self.update_sidebar()
self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0)
self.is_shown = True
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)
def change_callback(self):
if self.is_shown:
self.update_sidebar()
@ -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