(root)/
Python-3.12.0/
Lib/
idlelib/
sidebar.py
       1  """Line numbering implementation for IDLE as an extension.
       2  Includes BaseSideBar which can be extended for other sidebar based extensions
       3  """
       4  import contextlib
       5  import functools
       6  import itertools
       7  
       8  import tkinter as tk
       9  from tkinter.font import Font
      10  from idlelib.config import idleConf
      11  from idlelib.delegator import Delegator
      12  from idlelib import macosx
      13  
      14  
      15  def get_lineno(text, index):
      16      """Return the line number of an index in a Tk text widget."""
      17      text_index = text.index(index)
      18      return int(float(text_index)) if text_index else None
      19  
      20  
      21  def get_end_linenumber(text):
      22      """Return the number of the last line in a Tk text widget."""
      23      return get_lineno(text, 'end-1c')
      24  
      25  
      26  def get_displaylines(text, index):
      27      """Display height, in lines, of a logical line in a Tk text widget."""
      28      res = text.count(f"{index} linestart",
      29                       f"{index} lineend",
      30                       "displaylines")
      31      return res[0] if res else 0
      32  
      33  def get_widget_padding(widget):
      34      """Get the total padding of a Tk widget, including its border."""
      35      # TODO: use also in codecontext.py
      36      manager = widget.winfo_manager()
      37      if manager == 'pack':
      38          info = widget.pack_info()
      39      elif manager == 'grid':
      40          info = widget.grid_info()
      41      else:
      42          raise ValueError(f"Unsupported geometry manager: {manager}")
      43  
      44      # All values are passed through getint(), since some
      45      # values may be pixel objects, which can't simply be added to ints.
      46      padx = sum(map(widget.tk.getint, [
      47          info['padx'],
      48          widget.cget('padx'),
      49          widget.cget('border'),
      50      ]))
      51      pady = sum(map(widget.tk.getint, [
      52          info['pady'],
      53          widget.cget('pady'),
      54          widget.cget('border'),
      55      ]))
      56      return padx, pady
      57  
      58  
      59  @contextlib.contextmanager
      60  def temp_enable_text_widget(text):
      61      text.configure(state=tk.NORMAL)
      62      try:
      63          yield
      64      finally:
      65          text.configure(state=tk.DISABLED)
      66  
      67  
      68  class ESC[4;38;5;81mBaseSideBar:
      69      """A base class for sidebars using Text."""
      70      def __init__(self, editwin):
      71          self.editwin = editwin
      72          self.parent = editwin.text_frame
      73          self.text = editwin.text
      74  
      75          self.is_shown = False
      76  
      77          self.main_widget = self.init_widgets()
      78  
      79          self.bind_events()
      80  
      81          self.update_font()
      82          self.update_colors()
      83  
      84      def init_widgets(self):
      85          """Initialize the sidebar's widgets, returning the main widget."""
      86          raise NotImplementedError
      87  
      88      def update_font(self):
      89          """Update the sidebar text font, usually after config changes."""
      90          raise NotImplementedError
      91  
      92      def update_colors(self):
      93          """Update the sidebar text colors, usually after config changes."""
      94          raise NotImplementedError
      95  
      96      def grid(self):
      97          """Layout the widget, always using grid layout."""
      98          raise NotImplementedError
      99  
     100      def show_sidebar(self):
     101          if not self.is_shown:
     102              self.grid()
     103              self.is_shown = True
     104  
     105      def hide_sidebar(self):
     106          if self.is_shown:
     107              self.main_widget.grid_forget()
     108              self.is_shown = False
     109  
     110      def yscroll_event(self, *args, **kwargs):
     111          """Hook for vertical scrolling for sub-classes to override."""
     112          raise NotImplementedError
     113  
     114      def redirect_yscroll_event(self, *args, **kwargs):
     115          """Redirect vertical scrolling to the main editor text widget.
     116  
     117          The scroll bar is also updated.
     118          """
     119          self.editwin.vbar.set(*args)
     120          return self.yscroll_event(*args, **kwargs)
     121  
     122      def redirect_focusin_event(self, event):
     123          """Redirect focus-in events to the main editor text widget."""
     124          self.text.focus_set()
     125          return 'break'
     126  
     127      def redirect_mousebutton_event(self, event, event_name):
     128          """Redirect mouse button events to the main editor text widget."""
     129          self.text.focus_set()
     130          self.text.event_generate(event_name, x=0, y=event.y)
     131          return 'break'
     132  
     133      def redirect_mousewheel_event(self, event):
     134          """Redirect mouse wheel events to the editwin text widget."""
     135          self.text.event_generate('<MouseWheel>',
     136                                   x=0, y=event.y, delta=event.delta)
     137          return 'break'
     138  
     139      def bind_events(self):
     140          self.text['yscrollcommand'] = self.redirect_yscroll_event
     141  
     142          # Ensure focus is always redirected to the main editor text widget.
     143          self.main_widget.bind('<FocusIn>', self.redirect_focusin_event)
     144  
     145          # Redirect mouse scrolling to the main editor text widget.
     146          #
     147          # Note that without this, scrolling with the mouse only scrolls
     148          # the line numbers.
     149          self.main_widget.bind('<MouseWheel>', self.redirect_mousewheel_event)
     150  
     151          # Redirect mouse button events to the main editor text widget,
     152          # except for the left mouse button (1).
     153          #
     154          # Note: X-11 sends Button-4 and Button-5 events for the scroll wheel.
     155          def bind_mouse_event(event_name, target_event_name):
     156              handler = functools.partial(self.redirect_mousebutton_event,
     157                                          event_name=target_event_name)
     158              self.main_widget.bind(event_name, handler)
     159  
     160          for button in [2, 3, 4, 5]:
     161              for event_name in (f'<Button-{button}>',
     162                                 f'<ButtonRelease-{button}>',
     163                                 f'<B{button}-Motion>',
     164                                 ):
     165                  bind_mouse_event(event_name, target_event_name=event_name)
     166  
     167              # Convert double- and triple-click events to normal click events,
     168              # since event_generate() doesn't allow generating such events.
     169              for event_name in (f'<Double-Button-{button}>',
     170                                 f'<Triple-Button-{button}>',
     171                                 ):
     172                  bind_mouse_event(event_name,
     173                                   target_event_name=f'<Button-{button}>')
     174  
     175          # start_line is set upon <Button-1> to allow selecting a range of rows
     176          # by dragging.  It is cleared upon <ButtonRelease-1>.
     177          start_line = None
     178  
     179          # last_y is initially set upon <B1-Leave> and is continuously updated
     180          # upon <B1-Motion>, until <B1-Enter> or the mouse button is released.
     181          # It is used in text_auto_scroll(), which is called repeatedly and
     182          # does have a mouse event available.
     183          last_y = None
     184  
     185          # auto_scrolling_after_id is set whenever text_auto_scroll is
     186          # scheduled via .after().  It is used to stop the auto-scrolling
     187          # upon <B1-Enter>, as well as to avoid scheduling the function several
     188          # times in parallel.
     189          auto_scrolling_after_id = None
     190  
     191          def drag_update_selection_and_insert_mark(y_coord):
     192              """Helper function for drag and selection event handlers."""
     193              lineno = get_lineno(self.text, f"@0,{y_coord}")
     194              a, b = sorted([start_line, lineno])
     195              self.text.tag_remove("sel", "1.0", "end")
     196              self.text.tag_add("sel", f"{a}.0", f"{b+1}.0")
     197              self.text.mark_set("insert",
     198                                 f"{lineno if lineno == a else lineno + 1}.0")
     199  
     200          def b1_mousedown_handler(event):
     201              nonlocal start_line
     202              nonlocal last_y
     203              start_line = int(float(self.text.index(f"@0,{event.y}")))
     204              last_y = event.y
     205  
     206              drag_update_selection_and_insert_mark(event.y)
     207          self.main_widget.bind('<Button-1>', b1_mousedown_handler)
     208  
     209          def b1_mouseup_handler(event):
     210              # On mouse up, we're no longer dragging.  Set the shared persistent
     211              # variables to None to represent this.
     212              nonlocal start_line
     213              nonlocal last_y
     214              start_line = None
     215              last_y = None
     216              self.text.event_generate('<ButtonRelease-1>', x=0, y=event.y)
     217          self.main_widget.bind('<ButtonRelease-1>', b1_mouseup_handler)
     218  
     219          def b1_drag_handler(event):
     220              nonlocal last_y
     221              if last_y is None:  # i.e. if not currently dragging
     222                  return
     223              last_y = event.y
     224              drag_update_selection_and_insert_mark(event.y)
     225          self.main_widget.bind('<B1-Motion>', b1_drag_handler)
     226  
     227          def text_auto_scroll():
     228              """Mimic Text auto-scrolling when dragging outside of it."""
     229              # See: https://github.com/tcltk/tk/blob/064ff9941b4b80b85916a8afe86a6c21fd388b54/library/text.tcl#L670
     230              nonlocal auto_scrolling_after_id
     231              y = last_y
     232              if y is None:
     233                  self.main_widget.after_cancel(auto_scrolling_after_id)
     234                  auto_scrolling_after_id = None
     235                  return
     236              elif y < 0:
     237                  self.text.yview_scroll(-1 + y, 'pixels')
     238                  drag_update_selection_and_insert_mark(y)
     239              elif y > self.main_widget.winfo_height():
     240                  self.text.yview_scroll(1 + y - self.main_widget.winfo_height(),
     241                                         'pixels')
     242                  drag_update_selection_and_insert_mark(y)
     243              auto_scrolling_after_id = \
     244                  self.main_widget.after(50, text_auto_scroll)
     245  
     246          def b1_leave_handler(event):
     247              # Schedule the initial call to text_auto_scroll(), if not already
     248              # scheduled.
     249              nonlocal auto_scrolling_after_id
     250              if auto_scrolling_after_id is None:
     251                  nonlocal last_y
     252                  last_y = event.y
     253                  auto_scrolling_after_id = \
     254                      self.main_widget.after(0, text_auto_scroll)
     255          self.main_widget.bind('<B1-Leave>', b1_leave_handler)
     256  
     257          def b1_enter_handler(event):
     258              # Cancel the scheduling of text_auto_scroll(), if it exists.
     259              nonlocal auto_scrolling_after_id
     260              if auto_scrolling_after_id is not None:
     261                  self.main_widget.after_cancel(auto_scrolling_after_id)
     262                  auto_scrolling_after_id = None
     263          self.main_widget.bind('<B1-Enter>', b1_enter_handler)
     264  
     265  
     266  class ESC[4;38;5;81mEndLineDelegator(ESC[4;38;5;149mDelegator):
     267      """Generate callbacks with the current end line number.
     268  
     269      The provided callback is called after every insert and delete.
     270      """
     271      def __init__(self, changed_callback):
     272          Delegator.__init__(self)
     273          self.changed_callback = changed_callback
     274  
     275      def insert(self, index, chars, tags=None):
     276          self.delegate.insert(index, chars, tags)
     277          self.changed_callback(get_end_linenumber(self.delegate))
     278  
     279      def delete(self, index1, index2=None):
     280          self.delegate.delete(index1, index2)
     281          self.changed_callback(get_end_linenumber(self.delegate))
     282  
     283  
     284  class ESC[4;38;5;81mLineNumbers(ESC[4;38;5;149mBaseSideBar):
     285      """Line numbers support for editor windows."""
     286      def __init__(self, editwin):
     287          super().__init__(editwin)
     288  
     289          end_line_delegator = EndLineDelegator(self.update_sidebar_text)
     290          # Insert the delegator after the undo delegator, so that line numbers
     291          # are properly updated after undo and redo actions.
     292          self.editwin.per.insertfilterafter(end_line_delegator,
     293                                             after=self.editwin.undo)
     294  
     295      def init_widgets(self):
     296          _padx, pady = get_widget_padding(self.text)
     297          self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE,
     298                                      padx=2, pady=pady,
     299                                      borderwidth=0, highlightthickness=0)
     300          self.sidebar_text.config(state=tk.DISABLED)
     301  
     302          self.prev_end = 1
     303          self._sidebar_width_type = type(self.sidebar_text['width'])
     304          with temp_enable_text_widget(self.sidebar_text):
     305              self.sidebar_text.insert('insert', '1', 'linenumber')
     306          self.sidebar_text.config(takefocus=False, exportselection=False)
     307          self.sidebar_text.tag_config('linenumber', justify=tk.RIGHT)
     308  
     309          end = get_end_linenumber(self.text)
     310          self.update_sidebar_text(end)
     311  
     312          return self.sidebar_text
     313  
     314      def grid(self):
     315          self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW)
     316  
     317      def update_font(self):
     318          font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
     319          self.sidebar_text['font'] = font
     320  
     321      def update_colors(self):
     322          """Update the sidebar text colors, usually after config changes."""
     323          colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
     324          foreground = colors['foreground']
     325          background = colors['background']
     326          self.sidebar_text.config(
     327              fg=foreground, bg=background,
     328              selectforeground=foreground, selectbackground=background,
     329              inactiveselectbackground=background,
     330          )
     331  
     332      def update_sidebar_text(self, end):
     333          """
     334          Perform the following action:
     335          Each line sidebar_text contains the linenumber for that line
     336          Synchronize with editwin.text so that both sidebar_text and
     337          editwin.text contain the same number of lines"""
     338          if end == self.prev_end:
     339              return
     340  
     341          width_difference = len(str(end)) - len(str(self.prev_end))
     342          if width_difference:
     343              cur_width = int(float(self.sidebar_text['width']))
     344              new_width = cur_width + width_difference
     345              self.sidebar_text['width'] = self._sidebar_width_type(new_width)
     346  
     347          with temp_enable_text_widget(self.sidebar_text):
     348              if end > self.prev_end:
     349                  new_text = '\n'.join(itertools.chain(
     350                      [''],
     351                      map(str, range(self.prev_end + 1, end + 1)),
     352                  ))
     353                  self.sidebar_text.insert(f'end -1c', new_text, 'linenumber')
     354              else:
     355                  self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c')
     356  
     357          self.prev_end = end
     358  
     359      def yscroll_event(self, *args, **kwargs):
     360          self.sidebar_text.yview_moveto(args[0])
     361          return 'break'
     362  
     363  
     364  class ESC[4;38;5;81mWrappedLineHeightChangeDelegator(ESC[4;38;5;149mDelegator):
     365      def __init__(self, callback):
     366          """
     367          callback - Callable, will be called when an insert, delete or replace
     368                     action on the text widget may require updating the shell
     369                     sidebar.
     370          """
     371          Delegator.__init__(self)
     372          self.callback = callback
     373  
     374      def insert(self, index, chars, tags=None):
     375          is_single_line = '\n' not in chars
     376          if is_single_line:
     377              before_displaylines = get_displaylines(self, index)
     378  
     379          self.delegate.insert(index, chars, tags)
     380  
     381          if is_single_line:
     382              after_displaylines = get_displaylines(self, index)
     383              if after_displaylines == before_displaylines:
     384                  return  # no need to update the sidebar
     385  
     386          self.callback()
     387  
     388      def delete(self, index1, index2=None):
     389          if index2 is None:
     390              index2 = index1 + "+1c"
     391          is_single_line = get_lineno(self, index1) == get_lineno(self, index2)
     392          if is_single_line:
     393              before_displaylines = get_displaylines(self, index1)
     394  
     395          self.delegate.delete(index1, index2)
     396  
     397          if is_single_line:
     398              after_displaylines = get_displaylines(self, index1)
     399              if after_displaylines == before_displaylines:
     400                  return  # no need to update the sidebar
     401  
     402          self.callback()
     403  
     404  
     405  class ESC[4;38;5;81mShellSidebar(ESC[4;38;5;149mBaseSideBar):
     406      """Sidebar for the PyShell window, for prompts etc."""
     407      def __init__(self, editwin):
     408          self.canvas = None
     409          self.line_prompts = {}
     410  
     411          super().__init__(editwin)
     412  
     413          change_delegator = \
     414              WrappedLineHeightChangeDelegator(self.change_callback)
     415          # Insert the TextChangeDelegator after the last delegator, so that
     416          # the sidebar reflects final changes to the text widget contents.
     417          d = self.editwin.per.top
     418          if d.delegate is not self.text:
     419              while d.delegate is not self.editwin.per.bottom:
     420                  d = d.delegate
     421          self.editwin.per.insertfilterafter(change_delegator, after=d)
     422  
     423          self.is_shown = True
     424  
     425      def init_widgets(self):
     426          self.canvas = tk.Canvas(self.parent, width=30,
     427                                  borderwidth=0, highlightthickness=0,
     428                                  takefocus=False)
     429          self.update_sidebar()
     430          self.grid()
     431          return self.canvas
     432  
     433      def bind_events(self):
     434          super().bind_events()
     435  
     436          self.main_widget.bind(
     437              # AquaTk defines <2> as the right button, not <3>.
     438              "<Button-2>" if macosx.isAquaTk() else "<Button-3>",
     439              self.context_menu_event,
     440          )
     441  
     442      def context_menu_event(self, event):
     443          rmenu = tk.Menu(self.main_widget, tearoff=0)
     444          has_selection = bool(self.text.tag_nextrange('sel', '1.0'))
     445          def mkcmd(eventname):
     446              return lambda: self.text.event_generate(eventname)
     447          rmenu.add_command(label='Copy',
     448                            command=mkcmd('<<copy>>'),
     449                            state='normal' if has_selection else 'disabled')
     450          rmenu.add_command(label='Copy with prompts',
     451                            command=mkcmd('<<copy-with-prompts>>'),
     452                            state='normal' if has_selection else 'disabled')
     453          rmenu.tk_popup(event.x_root, event.y_root)
     454          return "break"
     455  
     456      def grid(self):
     457          self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0)
     458  
     459      def change_callback(self):
     460          if self.is_shown:
     461              self.update_sidebar()
     462  
     463      def update_sidebar(self):
     464          text = self.text
     465          text_tagnames = text.tag_names
     466          canvas = self.canvas
     467          line_prompts = self.line_prompts = {}
     468  
     469          canvas.delete(tk.ALL)
     470  
     471          index = text.index("@0,0")
     472          if index.split('.', 1)[1] != '0':
     473              index = text.index(f'{index}+1line linestart')
     474          while (lineinfo := text.dlineinfo(index)) is not None:
     475              y = lineinfo[1]
     476              prev_newline_tagnames = text_tagnames(f"{index} linestart -1c")
     477              prompt = (
     478                  '>>>' if "console" in prev_newline_tagnames else
     479                  '...' if "stdin" in prev_newline_tagnames else
     480                  None
     481              )
     482              if prompt:
     483                  canvas.create_text(2, y, anchor=tk.NW, text=prompt,
     484                                     font=self.font, fill=self.colors[0])
     485                  lineno = get_lineno(text, index)
     486                  line_prompts[lineno] = prompt
     487              index = text.index(f'{index}+1line')
     488  
     489      def yscroll_event(self, *args, **kwargs):
     490          """Redirect vertical scrolling to the main editor text widget.
     491  
     492          The scroll bar is also updated.
     493          """
     494          self.change_callback()
     495          return 'break'
     496  
     497      def update_font(self):
     498          """Update the sidebar text font, usually after config changes."""
     499          font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
     500          tk_font = Font(self.text, font=font)
     501          char_width = max(tk_font.measure(char) for char in ['>', '.'])
     502          self.canvas.configure(width=char_width * 3 + 4)
     503          self.font = font
     504          self.change_callback()
     505  
     506      def update_colors(self):
     507          """Update the sidebar text colors, usually after config changes."""
     508          linenumbers_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
     509          prompt_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'console')
     510          foreground = prompt_colors['foreground']
     511          background = linenumbers_colors['background']
     512          self.colors = (foreground, background)
     513          self.canvas.configure(background=background)
     514          self.change_callback()
     515  
     516  
     517  def _linenumbers_drag_scrolling(parent):  # htest #
     518      from idlelib.idle_test.test_sidebar import Dummy_editwin
     519  
     520      toplevel = tk.Toplevel(parent)
     521      text_frame = tk.Frame(toplevel)
     522      text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
     523      text_frame.rowconfigure(1, weight=1)
     524      text_frame.columnconfigure(1, weight=1)
     525  
     526      font = idleConf.GetFont(toplevel, 'main', 'EditorWindow')
     527      text = tk.Text(text_frame, width=80, height=24, wrap=tk.NONE, font=font)
     528      text.grid(row=1, column=1, sticky=tk.NSEW)
     529  
     530      editwin = Dummy_editwin(text)
     531      editwin.vbar = tk.Scrollbar(text_frame)
     532  
     533      linenumbers = LineNumbers(editwin)
     534      linenumbers.show_sidebar()
     535  
     536      text.insert('1.0', '\n'.join('a'*i for i in range(1, 101)))
     537  
     538  
     539  if __name__ == '__main__':
     540      from unittest import main
     541      main('idlelib.idle_test.test_sidebar', verbosity=2, exit=False)
     542  
     543      from idlelib.idle_test.htest import run
     544      run(_linenumbers_drag_scrolling)