123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544 |
- """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)
|