diff options
Diffstat (limited to 'Lib/idlelib/sidebar.py')
-rw-r--r-- | Lib/idlelib/sidebar.py | 411 |
1 files changed, 209 insertions, 202 deletions
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 |