jpayne@68: """Line numbering implementation for IDLE as an extension. jpayne@68: Includes BaseSideBar which can be extended for other sidebar based extensions jpayne@68: """ jpayne@68: import functools jpayne@68: import itertools jpayne@68: jpayne@68: import tkinter as tk jpayne@68: from idlelib.config import idleConf jpayne@68: from idlelib.delegator import Delegator jpayne@68: jpayne@68: jpayne@68: def get_end_linenumber(text): jpayne@68: """Utility to get the last line's number in a Tk text widget.""" jpayne@68: return int(float(text.index('end-1c'))) jpayne@68: jpayne@68: jpayne@68: def get_widget_padding(widget): jpayne@68: """Get the total padding of a Tk widget, including its border.""" jpayne@68: # TODO: use also in codecontext.py jpayne@68: manager = widget.winfo_manager() jpayne@68: if manager == 'pack': jpayne@68: info = widget.pack_info() jpayne@68: elif manager == 'grid': jpayne@68: info = widget.grid_info() jpayne@68: else: jpayne@68: raise ValueError(f"Unsupported geometry manager: {manager}") jpayne@68: jpayne@68: # All values are passed through getint(), since some jpayne@68: # values may be pixel objects, which can't simply be added to ints. jpayne@68: padx = sum(map(widget.tk.getint, [ jpayne@68: info['padx'], jpayne@68: widget.cget('padx'), jpayne@68: widget.cget('border'), jpayne@68: ])) jpayne@68: pady = sum(map(widget.tk.getint, [ jpayne@68: info['pady'], jpayne@68: widget.cget('pady'), jpayne@68: widget.cget('border'), jpayne@68: ])) jpayne@68: return padx, pady jpayne@68: jpayne@68: jpayne@68: class BaseSideBar: jpayne@68: """ jpayne@68: The base class for extensions which require a sidebar. jpayne@68: """ jpayne@68: def __init__(self, editwin): jpayne@68: self.editwin = editwin jpayne@68: self.parent = editwin.text_frame jpayne@68: self.text = editwin.text jpayne@68: jpayne@68: _padx, pady = get_widget_padding(self.text) jpayne@68: self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE, jpayne@68: padx=2, pady=pady, jpayne@68: borderwidth=0, highlightthickness=0) jpayne@68: self.sidebar_text.config(state=tk.DISABLED) jpayne@68: self.text['yscrollcommand'] = self.redirect_yscroll_event jpayne@68: self.update_font() jpayne@68: self.update_colors() jpayne@68: jpayne@68: self.is_shown = False jpayne@68: jpayne@68: def update_font(self): jpayne@68: """Update the sidebar text font, usually after config changes.""" jpayne@68: font = idleConf.GetFont(self.text, 'main', 'EditorWindow') jpayne@68: self._update_font(font) jpayne@68: jpayne@68: def _update_font(self, font): jpayne@68: self.sidebar_text['font'] = font jpayne@68: jpayne@68: def update_colors(self): jpayne@68: """Update the sidebar text colors, usually after config changes.""" jpayne@68: colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'normal') jpayne@68: self._update_colors(foreground=colors['foreground'], jpayne@68: background=colors['background']) jpayne@68: jpayne@68: def _update_colors(self, foreground, background): jpayne@68: self.sidebar_text.config( jpayne@68: fg=foreground, bg=background, jpayne@68: selectforeground=foreground, selectbackground=background, jpayne@68: inactiveselectbackground=background, jpayne@68: ) jpayne@68: jpayne@68: def show_sidebar(self): jpayne@68: if not self.is_shown: jpayne@68: self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW) jpayne@68: self.is_shown = True jpayne@68: jpayne@68: def hide_sidebar(self): jpayne@68: if self.is_shown: jpayne@68: self.sidebar_text.grid_forget() jpayne@68: self.is_shown = False jpayne@68: jpayne@68: def redirect_yscroll_event(self, *args, **kwargs): jpayne@68: """Redirect vertical scrolling to the main editor text widget. jpayne@68: jpayne@68: The scroll bar is also updated. jpayne@68: """ jpayne@68: self.editwin.vbar.set(*args) jpayne@68: self.sidebar_text.yview_moveto(args[0]) jpayne@68: return 'break' jpayne@68: jpayne@68: def redirect_focusin_event(self, event): jpayne@68: """Redirect focus-in events to the main editor text widget.""" jpayne@68: self.text.focus_set() jpayne@68: return 'break' jpayne@68: jpayne@68: def redirect_mousebutton_event(self, event, event_name): jpayne@68: """Redirect mouse button events to the main editor text widget.""" jpayne@68: self.text.focus_set() jpayne@68: self.text.event_generate(event_name, x=0, y=event.y) jpayne@68: return 'break' jpayne@68: jpayne@68: def redirect_mousewheel_event(self, event): jpayne@68: """Redirect mouse wheel events to the editwin text widget.""" jpayne@68: self.text.event_generate('', jpayne@68: x=0, y=event.y, delta=event.delta) jpayne@68: return 'break' jpayne@68: jpayne@68: jpayne@68: class EndLineDelegator(Delegator): jpayne@68: """Generate callbacks with the current end line number after jpayne@68: insert or delete operations""" jpayne@68: def __init__(self, changed_callback): jpayne@68: """ jpayne@68: changed_callback - Callable, will be called after insert jpayne@68: or delete operations with the current jpayne@68: end line number. jpayne@68: """ jpayne@68: Delegator.__init__(self) jpayne@68: self.changed_callback = changed_callback jpayne@68: jpayne@68: def insert(self, index, chars, tags=None): jpayne@68: self.delegate.insert(index, chars, tags) jpayne@68: self.changed_callback(get_end_linenumber(self.delegate)) jpayne@68: jpayne@68: def delete(self, index1, index2=None): jpayne@68: self.delegate.delete(index1, index2) jpayne@68: self.changed_callback(get_end_linenumber(self.delegate)) jpayne@68: jpayne@68: jpayne@68: class LineNumbers(BaseSideBar): jpayne@68: """Line numbers support for editor windows.""" jpayne@68: def __init__(self, editwin): jpayne@68: BaseSideBar.__init__(self, editwin) jpayne@68: self.prev_end = 1 jpayne@68: self._sidebar_width_type = type(self.sidebar_text['width']) jpayne@68: self.sidebar_text.config(state=tk.NORMAL) jpayne@68: self.sidebar_text.insert('insert', '1', 'linenumber') jpayne@68: self.sidebar_text.config(state=tk.DISABLED) jpayne@68: self.sidebar_text.config(takefocus=False, exportselection=False) jpayne@68: self.sidebar_text.tag_config('linenumber', justify=tk.RIGHT) jpayne@68: jpayne@68: self.bind_events() jpayne@68: jpayne@68: end = get_end_linenumber(self.text) jpayne@68: self.update_sidebar_text(end) jpayne@68: jpayne@68: end_line_delegator = EndLineDelegator(self.update_sidebar_text) jpayne@68: # Insert the delegator after the undo delegator, so that line numbers jpayne@68: # are properly updated after undo and redo actions. jpayne@68: end_line_delegator.setdelegate(self.editwin.undo.delegate) jpayne@68: self.editwin.undo.setdelegate(end_line_delegator) jpayne@68: # Reset the delegator caches of the delegators "above" the jpayne@68: # end line delegator we just inserted. jpayne@68: delegator = self.editwin.per.top jpayne@68: while delegator is not end_line_delegator: jpayne@68: delegator.resetcache() jpayne@68: delegator = delegator.delegate jpayne@68: jpayne@68: self.is_shown = False jpayne@68: jpayne@68: def bind_events(self): jpayne@68: # Ensure focus is always redirected to the main editor text widget. jpayne@68: self.sidebar_text.bind('', self.redirect_focusin_event) jpayne@68: jpayne@68: # Redirect mouse scrolling to the main editor text widget. jpayne@68: # jpayne@68: # Note that without this, scrolling with the mouse only scrolls jpayne@68: # the line numbers. jpayne@68: self.sidebar_text.bind('', self.redirect_mousewheel_event) jpayne@68: jpayne@68: # Redirect mouse button events to the main editor text widget, jpayne@68: # except for the left mouse button (1). jpayne@68: # jpayne@68: # Note: X-11 sends Button-4 and Button-5 events for the scroll wheel. jpayne@68: def bind_mouse_event(event_name, target_event_name): jpayne@68: handler = functools.partial(self.redirect_mousebutton_event, jpayne@68: event_name=target_event_name) jpayne@68: self.sidebar_text.bind(event_name, handler) jpayne@68: jpayne@68: for button in [2, 3, 4, 5]: jpayne@68: for event_name in (f'', jpayne@68: f'', jpayne@68: f'', jpayne@68: ): jpayne@68: bind_mouse_event(event_name, target_event_name=event_name) jpayne@68: jpayne@68: # Convert double- and triple-click events to normal click events, jpayne@68: # since event_generate() doesn't allow generating such events. jpayne@68: for event_name in (f'', jpayne@68: f'', jpayne@68: ): jpayne@68: bind_mouse_event(event_name, jpayne@68: target_event_name=f'') jpayne@68: jpayne@68: # This is set by b1_mousedown_handler() and read by jpayne@68: # drag_update_selection_and_insert_mark(), to know where dragging jpayne@68: # began. jpayne@68: start_line = None jpayne@68: # These are set by b1_motion_handler() and read by selection_handler(). jpayne@68: # last_y is passed this way since the mouse Y-coordinate is not jpayne@68: # available on selection event objects. last_yview is passed this way jpayne@68: # to recognize scrolling while the mouse isn't moving. jpayne@68: last_y = last_yview = None jpayne@68: jpayne@68: def b1_mousedown_handler(event): jpayne@68: # select the entire line jpayne@68: lineno = int(float(self.sidebar_text.index(f"@0,{event.y}"))) jpayne@68: self.text.tag_remove("sel", "1.0", "end") jpayne@68: self.text.tag_add("sel", f"{lineno}.0", f"{lineno+1}.0") jpayne@68: self.text.mark_set("insert", f"{lineno+1}.0") jpayne@68: jpayne@68: # remember this line in case this is the beginning of dragging jpayne@68: nonlocal start_line jpayne@68: start_line = lineno jpayne@68: self.sidebar_text.bind('', b1_mousedown_handler) jpayne@68: jpayne@68: def b1_mouseup_handler(event): jpayne@68: # On mouse up, we're no longer dragging. Set the shared persistent jpayne@68: # variables to None to represent this. jpayne@68: nonlocal start_line jpayne@68: nonlocal last_y jpayne@68: nonlocal last_yview jpayne@68: start_line = None jpayne@68: last_y = None jpayne@68: last_yview = None jpayne@68: self.sidebar_text.bind('', b1_mouseup_handler) jpayne@68: jpayne@68: def drag_update_selection_and_insert_mark(y_coord): jpayne@68: """Helper function for drag and selection event handlers.""" jpayne@68: lineno = int(float(self.sidebar_text.index(f"@0,{y_coord}"))) jpayne@68: a, b = sorted([start_line, lineno]) jpayne@68: self.text.tag_remove("sel", "1.0", "end") jpayne@68: self.text.tag_add("sel", f"{a}.0", f"{b+1}.0") jpayne@68: self.text.mark_set("insert", jpayne@68: f"{lineno if lineno == a else lineno + 1}.0") jpayne@68: jpayne@68: # Special handling of dragging with mouse button 1. In "normal" text jpayne@68: # widgets this selects text, but the line numbers text widget has jpayne@68: # selection disabled. Still, dragging triggers some selection-related jpayne@68: # functionality under the hood. Specifically, dragging to above or jpayne@68: # below the text widget triggers scrolling, in a way that bypasses the jpayne@68: # other scrolling synchronization mechanisms.i jpayne@68: def b1_drag_handler(event, *args): jpayne@68: nonlocal last_y jpayne@68: nonlocal last_yview jpayne@68: last_y = event.y jpayne@68: last_yview = self.sidebar_text.yview() jpayne@68: if not 0 <= last_y <= self.sidebar_text.winfo_height(): jpayne@68: self.text.yview_moveto(last_yview[0]) jpayne@68: drag_update_selection_and_insert_mark(event.y) jpayne@68: self.sidebar_text.bind('', b1_drag_handler) jpayne@68: jpayne@68: # With mouse-drag scrolling fixed by the above, there is still an edge- jpayne@68: # case we need to handle: When drag-scrolling, scrolling can continue jpayne@68: # while the mouse isn't moving, leading to the above fix not scrolling jpayne@68: # properly. jpayne@68: def selection_handler(event): jpayne@68: if last_yview is None: jpayne@68: # This logic is only needed while dragging. jpayne@68: return jpayne@68: yview = self.sidebar_text.yview() jpayne@68: if yview != last_yview: jpayne@68: self.text.yview_moveto(yview[0]) jpayne@68: drag_update_selection_and_insert_mark(last_y) jpayne@68: self.sidebar_text.bind('<>', selection_handler) jpayne@68: jpayne@68: def update_colors(self): jpayne@68: """Update the sidebar text colors, usually after config changes.""" jpayne@68: colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') jpayne@68: self._update_colors(foreground=colors['foreground'], jpayne@68: background=colors['background']) jpayne@68: jpayne@68: def update_sidebar_text(self, end): jpayne@68: """ jpayne@68: Perform the following action: jpayne@68: Each line sidebar_text contains the linenumber for that line jpayne@68: Synchronize with editwin.text so that both sidebar_text and jpayne@68: editwin.text contain the same number of lines""" jpayne@68: if end == self.prev_end: jpayne@68: return jpayne@68: jpayne@68: width_difference = len(str(end)) - len(str(self.prev_end)) jpayne@68: if width_difference: jpayne@68: cur_width = int(float(self.sidebar_text['width'])) jpayne@68: new_width = cur_width + width_difference jpayne@68: self.sidebar_text['width'] = self._sidebar_width_type(new_width) jpayne@68: jpayne@68: self.sidebar_text.config(state=tk.NORMAL) jpayne@68: if end > self.prev_end: jpayne@68: new_text = '\n'.join(itertools.chain( jpayne@68: [''], jpayne@68: map(str, range(self.prev_end + 1, end + 1)), jpayne@68: )) jpayne@68: self.sidebar_text.insert(f'end -1c', new_text, 'linenumber') jpayne@68: else: jpayne@68: self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c') jpayne@68: self.sidebar_text.config(state=tk.DISABLED) jpayne@68: jpayne@68: self.prev_end = end jpayne@68: jpayne@68: jpayne@68: def _linenumbers_drag_scrolling(parent): # htest # jpayne@68: from idlelib.idle_test.test_sidebar import Dummy_editwin jpayne@68: jpayne@68: toplevel = tk.Toplevel(parent) jpayne@68: text_frame = tk.Frame(toplevel) jpayne@68: text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) jpayne@68: text_frame.rowconfigure(1, weight=1) jpayne@68: text_frame.columnconfigure(1, weight=1) jpayne@68: jpayne@68: font = idleConf.GetFont(toplevel, 'main', 'EditorWindow') jpayne@68: text = tk.Text(text_frame, width=80, height=24, wrap=tk.NONE, font=font) jpayne@68: text.grid(row=1, column=1, sticky=tk.NSEW) jpayne@68: jpayne@68: editwin = Dummy_editwin(text) jpayne@68: editwin.vbar = tk.Scrollbar(text_frame) jpayne@68: jpayne@68: linenumbers = LineNumbers(editwin) jpayne@68: linenumbers.show_sidebar() jpayne@68: jpayne@68: text.insert('1.0', '\n'.join('a'*i for i in range(1, 101))) jpayne@68: jpayne@68: jpayne@68: if __name__ == '__main__': jpayne@68: from unittest import main jpayne@68: main('idlelib.idle_test.test_sidebar', verbosity=2, exit=False) jpayne@68: jpayne@68: from idlelib.idle_test.htest import run jpayne@68: run(_linenumbers_drag_scrolling)