| """Line numbering implementation for IDLE as an extension. |
| Includes BaseSideBar which can be extended for other sidebar based extensions |
| """ |
| import contextlib |
| import functools |
| import itertools |
| |
| 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.""" |
| text_index = text.index(index) |
| return int(float(text_index)) if text_index else None |
| |
| |
| def get_end_linenumber(text): |
| """Return the number of the last line in a Tk text widget.""" |
| return get_lineno(text, 'end-1c') |
| |
| |
| def get_displaylines(text, index): |
| """Display height, in lines, of a logical line in a Tk text widget.""" |
| res = text.count(f"{index} linestart", |
| f"{index} lineend", |
| "displaylines") |
| return res[0] if res else 0 |
| |
| def get_widget_padding(widget): |
| """Get the total padding of a Tk widget, including its border.""" |
| # TODO: use also in codecontext.py |
| manager = widget.winfo_manager() |
| if manager == 'pack': |
| info = widget.pack_info() |
| elif manager == 'grid': |
| info = widget.grid_info() |
| else: |
| raise ValueError(f"Unsupported geometry manager: {manager}") |
| |
| # All values are passed through getint(), since some |
| # values may be pixel objects, which can't simply be added to ints. |
| padx = sum(map(widget.tk.getint, [ |
| info['padx'], |
| widget.cget('padx'), |
| widget.cget('border'), |
| ])) |
| pady = sum(map(widget.tk.getint, [ |
| info['pady'], |
| widget.cget('pady'), |
| widget.cget('border'), |
| ])) |
| return padx, pady |
| |
| |
| @contextlib.contextmanager |
| def temp_enable_text_widget(text): |
| text.configure(state=tk.NORMAL) |
| try: |
| yield |
| finally: |
| text.configure(state=tk.DISABLED) |
| |
| |
| class BaseSideBar: |
| """A base class for sidebars using Text.""" |
| def __init__(self, editwin): |
| self.editwin = editwin |
| self.parent = editwin.text_frame |
| self.text = editwin.text |
| |
| self.is_shown = False |
| |
| self.main_widget = self.init_widgets() |
| |
| self.bind_events() |
| |
| self.update_font() |
| self.update_colors() |
| |
| 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.""" |
| raise NotImplementedError |
| |
| def update_colors(self): |
| """Update the sidebar text colors, usually after config changes.""" |
| raise NotImplementedError |
| |
| def grid(self): |
| """Layout the widget, always using grid layout.""" |
| raise NotImplementedError |
| |
| def show_sidebar(self): |
| if not self.is_shown: |
| self.grid() |
| self.is_shown = True |
| |
| def hide_sidebar(self): |
| if self.is_shown: |
| 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) |
| return self.yscroll_event(*args, **kwargs) |
| |
| 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): |
| 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. |
| |
| 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') |
| 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): |
| """ |
| Perform the following action: |
| Each line sidebar_text contains the linenumber for that line |
| Synchronize with editwin.text so that both sidebar_text and |
| editwin.text contain the same number of lines""" |
| if end == self.prev_end: |
| return |
| |
| width_difference = len(str(end)) - len(str(self.prev_end)) |
| if width_difference: |
| cur_width = int(float(self.sidebar_text['width'])) |
| new_width = cur_width + width_difference |
| self.sidebar_text['width'] = self._sidebar_width_type(new_width) |
| |
| with temp_enable_text_widget(self.sidebar_text): |
| if end > self.prev_end: |
| new_text = '\n'.join(itertools.chain( |
| [''], |
| map(str, range(self.prev_end + 1, end + 1)), |
| )) |
| self.sidebar_text.insert(f'end -1c', new_text, 'linenumber') |
| else: |
| self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c') |
| |
| 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): |
| """ |
| callback - Callable, will be called when an insert, delete or replace |
| action on the text widget may require updating the shell |
| sidebar. |
| """ |
| Delegator.__init__(self) |
| self.callback = callback |
| |
| def insert(self, index, chars, tags=None): |
| is_single_line = '\n' not in chars |
| if is_single_line: |
| before_displaylines = get_displaylines(self, index) |
| |
| self.delegate.insert(index, chars, tags) |
| |
| if is_single_line: |
| after_displaylines = get_displaylines(self, index) |
| if after_displaylines == before_displaylines: |
| return # no need to update the sidebar |
| |
| self.callback() |
| |
| def delete(self, index1, index2=None): |
| if index2 is None: |
| index2 = index1 + "+1c" |
| is_single_line = get_lineno(self, index1) == get_lineno(self, index2) |
| if is_single_line: |
| before_displaylines = get_displaylines(self, index1) |
| |
| self.delegate.delete(index1, index2) |
| |
| if is_single_line: |
| after_displaylines = get_displaylines(self, index1) |
| if after_displaylines == before_displaylines: |
| return # no need to update the sidebar |
| |
| self.callback() |
| |
| |
| 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 = \ |
| 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 |
| if d.delegate is not self.text: |
| while d.delegate is not self.editwin.per.bottom: |
| d = d.delegate |
| self.editwin.per.insertfilterafter(change_delegator, after=d) |
| |
| 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() |
| |
| def update_sidebar(self): |
| text = self.text |
| text_tagnames = text.tag_names |
| canvas = self.canvas |
| line_prompts = self.line_prompts = {} |
| |
| canvas.delete(tk.ALL) |
| |
| index = text.index("@0,0") |
| if index.split('.', 1)[1] != '0': |
| index = text.index(f'{index}+1line linestart') |
| while (lineinfo := text.dlineinfo(index)) is not None: |
| y = lineinfo[1] |
| prev_newline_tagnames = text_tagnames(f"{index} linestart -1c") |
| prompt = ( |
| '>>>' if "console" in prev_newline_tagnames else |
| '...' if "stdin" in prev_newline_tagnames else |
| None |
| ) |
| 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): |
| """Redirect vertical scrolling to the main editor text widget. |
| |
| The scroll bar is also updated. |
| """ |
| self.change_callback() |
| return 'break' |
| |
| def update_font(self): |
| """Update the sidebar text font, usually after config changes.""" |
| font = idleConf.GetFont(self.text, 'main', 'EditorWindow') |
| 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.font = font |
| self.change_callback() |
| |
| 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') |
| foreground = prompt_colors['foreground'] |
| background = linenumbers_colors['background'] |
| self.colors = (foreground, background) |
| self.canvas.configure(background=background) |
| self.change_callback() |
| |
| |
| def _linenumbers_drag_scrolling(parent): # htest # |
| from idlelib.idle_test.test_sidebar import Dummy_editwin |
| |
| toplevel = tk.Toplevel(parent) |
| text_frame = tk.Frame(toplevel) |
| text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) |
| text_frame.rowconfigure(1, weight=1) |
| text_frame.columnconfigure(1, weight=1) |
| |
| font = idleConf.GetFont(toplevel, 'main', 'EditorWindow') |
| text = tk.Text(text_frame, width=80, height=24, wrap=tk.NONE, font=font) |
| text.grid(row=1, column=1, sticky=tk.NSEW) |
| |
| editwin = Dummy_editwin(text) |
| editwin.vbar = tk.Scrollbar(text_frame) |
| |
| linenumbers = LineNumbers(editwin) |
| linenumbers.show_sidebar() |
| |
| text.insert('1.0', '\n'.join('a'*i for i in range(1, 101))) |
| |
| |
| if __name__ == '__main__': |
| from unittest import main |
| main('idlelib.idle_test.test_sidebar', verbosity=2, exit=False) |
| |
| from idlelib.idle_test.htest import run |
| run(_linenumbers_drag_scrolling) |