bpo-17535: IDLE editor line numbers (GH-14030)
This commit is contained in:
parent
1ebee37dde
commit
7123ea009b
|
@ -292,20 +292,29 @@ Configure IDLE
|
|||
fonts, indentation, keybindings, text color themes, startup windows and
|
||||
size, additional help sources, and extensions. On macOS, open the
|
||||
configuration dialog by selecting Preferences in the application
|
||||
menu. For more, see
|
||||
menu. For more details, see
|
||||
:ref:`Setting preferences <preferences>` under Help and preferences.
|
||||
|
||||
Most configuration options apply to all windows or all future windows.
|
||||
The option items below only apply to the active window.
|
||||
|
||||
Show/Hide Code Context (Editor Window only)
|
||||
Open a pane at the top of the edit window which shows the block context
|
||||
of the code which has scrolled above the top of the window. See
|
||||
:ref:`Code Context <code-context>` in the Editing and Navigation section below.
|
||||
:ref:`Code Context <code-context>` in the Editing and Navigation section
|
||||
below.
|
||||
|
||||
Show/Hide Line Numbers (Editor Window only)
|
||||
Open a column to the left of the edit window which shows the number
|
||||
of each line of text. The default is off, which may be changed in the
|
||||
preferences (see :ref:`Setting preferences <preferences>`).
|
||||
|
||||
Zoom/Restore Height
|
||||
Toggles the window between normal size and maximum height. The initial size
|
||||
defaults to 40 lines by 80 chars unless changed on the General tab of the
|
||||
Configure IDLE dialog. The maximum height for a screen is determined by
|
||||
momentarily maximizing a window the first time one is zoomed on the screen.
|
||||
Changing screen settings may invalidate the saved height. This toogle has
|
||||
Changing screen settings may invalidate the saved height. This toggle has
|
||||
no effect when a window is maximized.
|
||||
|
||||
Window menu (Shell and Editor)
|
||||
|
|
|
@ -1017,6 +1017,13 @@ by right-clicking the button. (Contributed by Tal Einat in :issue:`1529353`.)
|
|||
|
||||
The changes above have been backported to 3.6 maintenance releases.
|
||||
|
||||
New in 3.7.5:
|
||||
|
||||
Add optional line numbers for IDLE editor windows. Windows
|
||||
open without line numbers unless set otherwise in the General
|
||||
tab of the configuration dialog.
|
||||
(Contributed by Tal Einat and Saimadhav Heblikar in :issue:`17535`.)
|
||||
|
||||
|
||||
importlib
|
||||
---------
|
||||
|
|
|
@ -515,6 +515,11 @@ for certain types of invalid or corrupt gzip files.
|
|||
idlelib and IDLE
|
||||
----------------
|
||||
|
||||
Add optional line numbers for IDLE editor windows. Windows
|
||||
open without line numbers unless set otherwise in the General
|
||||
tab of the configuration dialog.
|
||||
(Contributed by Tal Einat and Saimadhav Heblikar in :issue:`17535`.)
|
||||
|
||||
Output over N lines (50 by default) is squeezed down to a button.
|
||||
N can be changed in the PyShell section of the General page of the
|
||||
Settings dialog. Fewer, but possibly extra long, lines can be squeezed by
|
||||
|
|
|
@ -13,7 +13,7 @@ import re
|
|||
from sys import maxsize as INFINITY
|
||||
|
||||
import tkinter
|
||||
from tkinter.constants import TOP, X, SUNKEN
|
||||
from tkinter.constants import NSEW, SUNKEN
|
||||
|
||||
from idlelib.config import idleConf
|
||||
|
||||
|
@ -67,6 +67,7 @@ class CodeContext:
|
|||
|
||||
def _reset(self):
|
||||
self.context = None
|
||||
self.cell00 = None
|
||||
self.t1 = None
|
||||
self.topvisible = 1
|
||||
self.info = [(0, -1, "", False)]
|
||||
|
@ -105,25 +106,37 @@ class CodeContext:
|
|||
padx = 0
|
||||
border = 0
|
||||
for widget in widgets:
|
||||
padx += widget.tk.getint(widget.pack_info()['padx'])
|
||||
info = (widget.grid_info()
|
||||
if widget is self.editwin.text
|
||||
else widget.pack_info())
|
||||
padx += widget.tk.getint(info['padx'])
|
||||
padx += widget.tk.getint(widget.cget('padx'))
|
||||
border += widget.tk.getint(widget.cget('border'))
|
||||
self.context = tkinter.Text(
|
||||
self.editwin.top, font=self.text['font'],
|
||||
self.editwin.text_frame,
|
||||
height=1,
|
||||
width=1, # Don't request more than we get.
|
||||
highlightthickness=0,
|
||||
padx=padx, border=border, relief=SUNKEN, state='disabled')
|
||||
self.update_font()
|
||||
self.update_highlight_colors()
|
||||
self.context.bind('<ButtonRelease-1>', self.jumptoline)
|
||||
# Get the current context and initiate the recurring update event.
|
||||
self.timer_event()
|
||||
# Pack the context widget before and above the text_frame widget,
|
||||
# thus ensuring that it will appear directly above text_frame.
|
||||
self.context.pack(side=TOP, fill=X, expand=False,
|
||||
before=self.editwin.text_frame)
|
||||
# Grid the context widget above the text widget.
|
||||
self.context.grid(row=0, column=1, sticky=NSEW)
|
||||
|
||||
line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(),
|
||||
'linenumber')
|
||||
self.cell00 = tkinter.Frame(self.editwin.text_frame,
|
||||
bg=line_number_colors['background'])
|
||||
self.cell00.grid(row=0, column=0, sticky=NSEW)
|
||||
menu_status = 'Hide'
|
||||
else:
|
||||
self.context.destroy()
|
||||
self.context = None
|
||||
self.cell00.destroy()
|
||||
self.cell00 = None
|
||||
self.text.after_cancel(self.t1)
|
||||
self._reset()
|
||||
menu_status = 'Show'
|
||||
|
@ -221,8 +234,9 @@ class CodeContext:
|
|||
self.update_code_context()
|
||||
self.t1 = self.text.after(self.UPDATEINTERVAL, self.timer_event)
|
||||
|
||||
def update_font(self, font):
|
||||
def update_font(self):
|
||||
if self.context is not None:
|
||||
font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
|
||||
self.context['font'] = font
|
||||
|
||||
def update_highlight_colors(self):
|
||||
|
@ -231,6 +245,11 @@ class CodeContext:
|
|||
self.context['background'] = colors['background']
|
||||
self.context['foreground'] = colors['foreground']
|
||||
|
||||
if self.cell00 is not None:
|
||||
line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(),
|
||||
'linenumber')
|
||||
self.cell00.config(bg=line_number_colors['background'])
|
||||
|
||||
|
||||
CodeContext.reload()
|
||||
|
||||
|
|
|
@ -22,6 +22,10 @@ hit-foreground= #ffffff
|
|||
hit-background= #000000
|
||||
error-foreground= #000000
|
||||
error-background= #ff7777
|
||||
context-foreground= #000000
|
||||
context-background= lightgray
|
||||
linenumber-foreground= gray
|
||||
linenumber-background= #ffffff
|
||||
#cursor (only foreground can be set, restart IDLE)
|
||||
cursor-foreground= black
|
||||
#shell window
|
||||
|
@ -31,8 +35,6 @@ stderr-foreground= red
|
|||
stderr-background= #ffffff
|
||||
console-foreground= #770000
|
||||
console-background= #ffffff
|
||||
context-foreground= #000000
|
||||
context-background= lightgray
|
||||
|
||||
[IDLE New]
|
||||
normal-foreground= #000000
|
||||
|
@ -55,6 +57,10 @@ hit-foreground= #ffffff
|
|||
hit-background= #000000
|
||||
error-foreground= #000000
|
||||
error-background= #ff7777
|
||||
context-foreground= #000000
|
||||
context-background= lightgray
|
||||
linenumber-foreground= gray
|
||||
linenumber-background= #ffffff
|
||||
#cursor (only foreground can be set, restart IDLE)
|
||||
cursor-foreground= black
|
||||
#shell window
|
||||
|
@ -64,8 +70,6 @@ stderr-foreground= red
|
|||
stderr-background= #ffffff
|
||||
console-foreground= #770000
|
||||
console-background= #ffffff
|
||||
context-foreground= #000000
|
||||
context-background= lightgray
|
||||
|
||||
[IDLE Dark]
|
||||
comment-foreground = #dd0000
|
||||
|
@ -97,3 +101,5 @@ comment-background = #002240
|
|||
break-foreground = #FFFFFF
|
||||
context-foreground= #ffffff
|
||||
context-background= #454545
|
||||
linenumber-foreground= gray
|
||||
linenumber-background= #002240
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
# Additional help sources are listed in the [HelpFiles] section below
|
||||
# and should be viewable by a web browser (or the Windows Help viewer in
|
||||
# the case of .chm files). These sources will be listed on the Help
|
||||
# menu. The pattern, and two examples, are
|
||||
# menu. The pattern, and two examples, are:
|
||||
#
|
||||
# <sequence_number = menu item;/path/to/help/source>
|
||||
# 1 = IDLE;C:/Programs/Python36/Lib/idlelib/help.html
|
||||
|
@ -65,6 +65,7 @@ font= TkFixedFont
|
|||
font-size= 10
|
||||
font-bold= 0
|
||||
encoding= none
|
||||
line-numbers-default= 0
|
||||
|
||||
[PyShell]
|
||||
auto-squeeze-min-lines= 50
|
||||
|
|
|
@ -319,6 +319,10 @@ class IdleConf:
|
|||
'hit-background':'#000000',
|
||||
'error-foreground':'#ffffff',
|
||||
'error-background':'#000000',
|
||||
'context-foreground':'#000000',
|
||||
'context-background':'#ffffff',
|
||||
'linenumber-foreground':'#000000',
|
||||
'linenumber-background':'#ffffff',
|
||||
#cursor (only foreground can be set)
|
||||
'cursor-foreground':'#000000',
|
||||
#shell window
|
||||
|
@ -328,11 +332,11 @@ class IdleConf:
|
|||
'stderr-background':'#ffffff',
|
||||
'console-foreground':'#000000',
|
||||
'console-background':'#ffffff',
|
||||
'context-foreground':'#000000',
|
||||
'context-background':'#ffffff',
|
||||
}
|
||||
for element in theme:
|
||||
if not cfgParser.has_option(themeName, element):
|
||||
if not (cfgParser.has_option(themeName, element) or
|
||||
# Skip warning for new elements.
|
||||
element.startswith(('context-', 'linenumber-'))):
|
||||
# Print warning that will return a default color
|
||||
warning = ('\n Warning: config.IdleConf.GetThemeDict'
|
||||
' -\n problem retrieving theme element %r'
|
||||
|
|
|
@ -819,6 +819,7 @@ class HighPage(Frame):
|
|||
'Shell Error Text': ('error', '12'),
|
||||
'Shell Stdout Text': ('stdout', '13'),
|
||||
'Shell Stderr Text': ('stderr', '14'),
|
||||
'Line Number': ('linenumber', '16'),
|
||||
}
|
||||
self.builtin_name = tracers.add(
|
||||
StringVar(self), self.var_changed_builtin_name)
|
||||
|
@ -866,6 +867,11 @@ class HighPage(Frame):
|
|||
('stderr', 'stderr'), ('\n\n', 'normal'))
|
||||
for texttag in text_and_tags:
|
||||
text.insert(END, texttag[0], texttag[1])
|
||||
n_lines = len(text.get('1.0', END).splitlines())
|
||||
for lineno in range(1, n_lines + 1):
|
||||
text.insert(f'{lineno}.0',
|
||||
f'{lineno:{len(str(n_lines))}d} ',
|
||||
'linenumber')
|
||||
for element in self.theme_elements:
|
||||
def tem(event, elem=element):
|
||||
# event.widget.winfo_top_level().highlight_target.set(elem)
|
||||
|
@ -1827,6 +1833,9 @@ class GenPage(Frame):
|
|||
frame_format: Frame
|
||||
format_width_title: Label
|
||||
(*)format_width_int: Entry - format_width
|
||||
frame_line_numbers_default: Frame
|
||||
line_numbers_default_title: Label
|
||||
(*)line_numbers_default_bool: Checkbutton - line_numbers_default
|
||||
frame_context: Frame
|
||||
context_title: Label
|
||||
(*)context_int: Entry - context_lines
|
||||
|
@ -1866,6 +1875,9 @@ class GenPage(Frame):
|
|||
IntVar(self), ('main', 'General', 'autosave'))
|
||||
self.format_width = tracers.add(
|
||||
StringVar(self), ('extensions', 'FormatParagraph', 'max-width'))
|
||||
self.line_numbers_default = tracers.add(
|
||||
BooleanVar(self),
|
||||
('main', 'EditorWindow', 'line-numbers-default'))
|
||||
self.context_lines = tracers.add(
|
||||
StringVar(self), ('extensions', 'CodeContext', 'maxlines'))
|
||||
|
||||
|
@ -1944,6 +1956,14 @@ class GenPage(Frame):
|
|||
validatecommand=self.digits_only, validate='key',
|
||||
)
|
||||
|
||||
frame_line_numbers_default = Frame(frame_editor, borderwidth=0)
|
||||
line_numbers_default_title = Label(
|
||||
frame_line_numbers_default, text='Show line numbers in new windows')
|
||||
self.line_numbers_default_bool = Checkbutton(
|
||||
frame_line_numbers_default,
|
||||
variable=self.line_numbers_default,
|
||||
width=1)
|
||||
|
||||
frame_context = Frame(frame_editor, borderwidth=0)
|
||||
context_title = Label(frame_context, text='Max Context Lines :')
|
||||
self.context_int = Entry(
|
||||
|
@ -2021,6 +2041,10 @@ class GenPage(Frame):
|
|||
frame_format.pack(side=TOP, padx=5, pady=0, fill=X)
|
||||
format_width_title.pack(side=LEFT, anchor=W, padx=5, pady=5)
|
||||
self.format_width_int.pack(side=TOP, padx=10, pady=5)
|
||||
# frame_line_numbers_default.
|
||||
frame_line_numbers_default.pack(side=TOP, padx=5, pady=0, fill=X)
|
||||
line_numbers_default_title.pack(side=LEFT, anchor=W, padx=5, pady=5)
|
||||
self.line_numbers_default_bool.pack(side=LEFT, padx=5, pady=5)
|
||||
# frame_context.
|
||||
frame_context.pack(side=TOP, padx=5, pady=0, fill=X)
|
||||
context_title.pack(side=LEFT, anchor=W, padx=5, pady=5)
|
||||
|
@ -2063,6 +2087,8 @@ class GenPage(Frame):
|
|||
'main', 'General', 'autosave', default=0, type='bool'))
|
||||
self.format_width.set(idleConf.GetOption(
|
||||
'extensions', 'FormatParagraph', 'max-width', type='int'))
|
||||
self.line_numbers_default.set(idleConf.GetOption(
|
||||
'main', 'EditorWindow', 'line-numbers-default', type='bool'))
|
||||
self.context_lines.set(idleConf.GetOption(
|
||||
'extensions', 'CodeContext', 'maxlines', type='int'))
|
||||
|
||||
|
|
|
@ -53,6 +53,7 @@ class EditorWindow(object):
|
|||
from idlelib.autoexpand import AutoExpand
|
||||
from idlelib.calltip import Calltip
|
||||
from idlelib.codecontext import CodeContext
|
||||
from idlelib.sidebar import LineNumbers
|
||||
from idlelib.format import FormatParagraph, FormatRegion, Indents, Rstrip
|
||||
from idlelib.parenmatch import ParenMatch
|
||||
from idlelib.squeezer import Squeezer
|
||||
|
@ -61,7 +62,8 @@ class EditorWindow(object):
|
|||
filesystemencoding = sys.getfilesystemencoding() # for file names
|
||||
help_url = None
|
||||
|
||||
allow_codecontext = True
|
||||
allow_code_context = True
|
||||
allow_line_numbers = True
|
||||
|
||||
def __init__(self, flist=None, filename=None, key=None, root=None):
|
||||
# Delay import: runscript imports pyshell imports EditorWindow.
|
||||
|
@ -198,12 +200,14 @@ class EditorWindow(object):
|
|||
text.bind("<<open-turtle-demo>>", self.open_turtle_demo)
|
||||
|
||||
self.set_status_bar()
|
||||
text_frame.pack(side=LEFT, fill=BOTH, expand=1)
|
||||
text_frame.rowconfigure(1, weight=1)
|
||||
text_frame.columnconfigure(1, weight=1)
|
||||
vbar['command'] = self.handle_yview
|
||||
vbar.pack(side=RIGHT, fill=Y)
|
||||
vbar.grid(row=1, column=2, sticky=NSEW)
|
||||
text['yscrollcommand'] = vbar.set
|
||||
text['font'] = idleConf.GetFont(self.root, 'main', 'EditorWindow')
|
||||
text_frame.pack(side=LEFT, fill=BOTH, expand=1)
|
||||
text.pack(side=TOP, fill=BOTH, expand=1)
|
||||
text.grid(row=1, column=1, sticky=NSEW)
|
||||
text.focus_set()
|
||||
|
||||
# usetabs true -> literal tab characters are used by indent and
|
||||
|
@ -250,7 +254,8 @@ class EditorWindow(object):
|
|||
self.good_load = False
|
||||
self.set_indentation_params(False)
|
||||
self.color = None # initialized below in self.ResetColorizer
|
||||
self.codecontext = None
|
||||
self.code_context = None # optionally initialized later below
|
||||
self.line_numbers = None # optionally initialized later below
|
||||
if filename:
|
||||
if os.path.exists(filename) and not os.path.isdir(filename):
|
||||
if io.loadfile(filename):
|
||||
|
@ -316,10 +321,20 @@ class EditorWindow(object):
|
|||
text.bind("<<refresh-calltip>>", ctip.refresh_calltip_event)
|
||||
text.bind("<<force-open-calltip>>", ctip.force_open_calltip_event)
|
||||
text.bind("<<zoom-height>>", self.ZoomHeight(self).zoom_height_event)
|
||||
if self.allow_codecontext:
|
||||
self.codecontext = self.CodeContext(self)
|
||||
if self.allow_code_context:
|
||||
self.code_context = self.CodeContext(self)
|
||||
text.bind("<<toggle-code-context>>",
|
||||
self.codecontext.toggle_code_context_event)
|
||||
self.code_context.toggle_code_context_event)
|
||||
else:
|
||||
self.update_menu_state('options', '*Code Context', 'disabled')
|
||||
if self.allow_line_numbers:
|
||||
self.line_numbers = self.LineNumbers(self)
|
||||
if idleConf.GetOption('main', 'EditorWindow',
|
||||
'line-numbers-default', type='bool'):
|
||||
self.toggle_line_numbers_event()
|
||||
text.bind("<<toggle-line-numbers>>", self.toggle_line_numbers_event)
|
||||
else:
|
||||
self.update_menu_state('options', '*Line Numbers', 'disabled')
|
||||
|
||||
def _filename_to_unicode(self, filename):
|
||||
"""Return filename as BMP unicode so displayable in Tk."""
|
||||
|
@ -779,8 +794,11 @@ class EditorWindow(object):
|
|||
self._addcolorizer()
|
||||
EditorWindow.color_config(self.text)
|
||||
|
||||
if self.codecontext is not None:
|
||||
self.codecontext.update_highlight_colors()
|
||||
if self.code_context is not None:
|
||||
self.code_context.update_highlight_colors()
|
||||
|
||||
if self.line_numbers is not None:
|
||||
self.line_numbers.update_colors()
|
||||
|
||||
IDENTCHARS = string.ascii_letters + string.digits + "_"
|
||||
|
||||
|
@ -799,11 +817,16 @@ class EditorWindow(object):
|
|||
"Update the text widgets' font if it is changed"
|
||||
# Called from configdialog.py
|
||||
|
||||
new_font = idleConf.GetFont(self.root, 'main', 'EditorWindow')
|
||||
# Update the code context widget first, since its height affects
|
||||
# the height of the text widget. This avoids double re-rendering.
|
||||
if self.codecontext is not None:
|
||||
self.codecontext.update_font(new_font)
|
||||
if self.code_context is not None:
|
||||
self.code_context.update_font()
|
||||
# Next, update the line numbers widget, since its width affects
|
||||
# the width of the text widget.
|
||||
if self.line_numbers is not None:
|
||||
self.line_numbers.update_font()
|
||||
# Finally, update the main text widget.
|
||||
new_font = idleConf.GetFont(self.root, 'main', 'EditorWindow')
|
||||
self.text['font'] = new_font
|
||||
|
||||
def RemoveKeybindings(self):
|
||||
|
@ -1467,6 +1490,19 @@ class EditorWindow(object):
|
|||
indentsmall = indentlarge = 0
|
||||
return indentlarge - indentsmall
|
||||
|
||||
def toggle_line_numbers_event(self, event=None):
|
||||
if self.line_numbers is None:
|
||||
return
|
||||
|
||||
if self.line_numbers.is_shown:
|
||||
self.line_numbers.hide_sidebar()
|
||||
menu_label = "Show"
|
||||
else:
|
||||
self.line_numbers.show_sidebar()
|
||||
menu_label = "Hide"
|
||||
self.update_menu_label(menu='options', index='*Line Numbers',
|
||||
label=f'{menu_label} Line Numbers')
|
||||
|
||||
# "line.col" -> line, as an int
|
||||
def index2line(index):
|
||||
return int(float(index))
|
||||
|
|
|
@ -271,10 +271,15 @@ fonts, indentation, keybindings, text color themes, startup windows and
|
|||
size, additional help sources, and extensions. On macOS, open the
|
||||
configuration dialog by selecting Preferences in the application
|
||||
menu. For more, see
|
||||
<a class="reference internal" href="#preferences"><span class="std std-ref">Setting preferences</span></a> under Help and preferences.</dd>
|
||||
<a class="reference internal" href="#preferences"><span class="std std-ref">Setting preferences</span></a> under Help and preferences.
|
||||
Most configuration options apply to all windows or all future windows.
|
||||
The option items below only apply to the active window.</dd>
|
||||
<dt>Show/Hide Code Context (Editor Window only)</dt><dd>Open a pane at the top of the edit window which shows the block context
|
||||
of the code which has scrolled above the top of the window. See
|
||||
<a class="reference internal" href="#code-context"><span class="std std-ref">Code Context</span></a> in the Editing and Navigation section below.</dd>
|
||||
<dt>Line Numbers (Editor Window only)</dt><dd>Open a column to the left of the edit window which shows the linenumber
|
||||
of each line of text. The default is off unless configured on
|
||||
(see <a class="reference internal" href="#preferences"><span class="std std-ref">Setting preferences</span></a>).</dd>
|
||||
<dt>Zoom/Restore Height</dt><dd>Toggles the window between normal size and maximum height. The initial size
|
||||
defaults to 40 lines by 80 chars unless changed on the General tab of the
|
||||
Configure IDLE dialog. The maximum height for a screen is determined by
|
||||
|
@ -607,7 +612,7 @@ IDLE should be started in a command line window. The secondary subprocess
|
|||
will then be attached to that window for input and output.</p>
|
||||
<p>The IDLE code running in the execution process adds frames to the call stack
|
||||
that would not be there otherwise. IDLE wraps <code class="docutils literal notranslate"><span class="pre">sys.getrecursionlimit</span></code> and
|
||||
<code class="docutils literal notranslate"><span class="pre">sys.setrecursionlimit</span></code> to reduce their visibility.</p>
|
||||
<code class="docutils literal notranslate"><span class="pre">sys.setrecursionlimit</span></code> to reduce the effect of the additional stack frames.</p>
|
||||
<p>If <code class="docutils literal notranslate"><span class="pre">sys</span></code> is reset by user code, such as with <code class="docutils literal notranslate"><span class="pre">importlib.reload(sys)</span></code>,
|
||||
IDLE’s changes are lost and input from the keyboard and output to the screen
|
||||
will not work correctly.</p>
|
||||
|
@ -895,7 +900,7 @@ also used for testing.</p>
|
|||
<br />
|
||||
<br />
|
||||
|
||||
Last updated on Jul 04, 2019.
|
||||
Last updated on Jul 23, 2019.
|
||||
<a href="https://docs.python.org/3/bugs.html">Found a bug</a>?
|
||||
<br />
|
||||
|
||||
|
|
|
@ -67,6 +67,7 @@ outwin.OutputWindow (indirectly being tested with grep test)
|
|||
|
||||
import idlelib.pyshell # Set Windows DPI awareness before Tk().
|
||||
from importlib import import_module
|
||||
import textwrap
|
||||
import tkinter as tk
|
||||
from tkinter.ttk import Scrollbar
|
||||
tk.NoDefaultRoot()
|
||||
|
@ -205,6 +206,19 @@ _io_binding_spec = {
|
|||
"Check that changes were saved by opening the file elsewhere."
|
||||
}
|
||||
|
||||
_linenumbers_drag_scrolling_spec = {
|
||||
'file': 'sidebar',
|
||||
'kwds': {},
|
||||
'msg': textwrap.dedent("""\
|
||||
Click on the line numbers and drag down below the edge of the
|
||||
window, moving the mouse a bit and then leaving it there for a while.
|
||||
The text and line numbers should gradually scroll down, with the
|
||||
selection updated continuously.
|
||||
Do the same as above, dragging to above the window. The text and line
|
||||
numbers should gradually scroll up, with the selection updated
|
||||
continuously."""),
|
||||
}
|
||||
|
||||
_multi_call_spec = {
|
||||
'file': 'multicall',
|
||||
'kwds': {},
|
||||
|
|
|
@ -4,7 +4,7 @@ from idlelib import codecontext
|
|||
import unittest
|
||||
import unittest.mock
|
||||
from test.support import requires
|
||||
from tkinter import Tk, Frame, Text, TclError
|
||||
from tkinter import NSEW, Tk, Frame, Text, TclError
|
||||
|
||||
from unittest import mock
|
||||
import re
|
||||
|
@ -62,7 +62,7 @@ class CodeContextTest(unittest.TestCase):
|
|||
text.insert('1.0', code_sample)
|
||||
# Need to pack for creation of code context text widget.
|
||||
frame.pack(side='left', fill='both', expand=1)
|
||||
text.pack(side='top', fill='both', expand=1)
|
||||
text.grid(row=1, column=1, sticky=NSEW)
|
||||
cls.editor = DummyEditwin(root, frame, text)
|
||||
codecontext.idleConf.userCfg = testcfg
|
||||
|
||||
|
@ -77,6 +77,7 @@ class CodeContextTest(unittest.TestCase):
|
|||
|
||||
def setUp(self):
|
||||
self.text.yview(0)
|
||||
self.text['font'] = 'TkFixedFont'
|
||||
self.cc = codecontext.CodeContext(self.editor)
|
||||
|
||||
self.highlight_cfg = {"background": '#abcdef',
|
||||
|
@ -86,10 +87,18 @@ class CodeContextTest(unittest.TestCase):
|
|||
if element == 'context':
|
||||
return self.highlight_cfg
|
||||
return orig_idleConf_GetHighlight(theme, element)
|
||||
patcher = unittest.mock.patch.object(
|
||||
GetHighlight_patcher = unittest.mock.patch.object(
|
||||
codecontext.idleConf, 'GetHighlight', mock_idleconf_GetHighlight)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
GetHighlight_patcher.start()
|
||||
self.addCleanup(GetHighlight_patcher.stop)
|
||||
|
||||
self.font_override = 'TkFixedFont'
|
||||
def mock_idleconf_GetFont(root, configType, section):
|
||||
return self.font_override
|
||||
GetFont_patcher = unittest.mock.patch.object(
|
||||
codecontext.idleConf, 'GetFont', mock_idleconf_GetFont)
|
||||
GetFont_patcher.start()
|
||||
self.addCleanup(GetFont_patcher.stop)
|
||||
|
||||
def tearDown(self):
|
||||
if self.cc.context:
|
||||
|
@ -339,69 +348,59 @@ class CodeContextTest(unittest.TestCase):
|
|||
def test_font(self):
|
||||
eq = self.assertEqual
|
||||
cc = self.cc
|
||||
save_font = cc.text['font']
|
||||
|
||||
orig_font = cc.text['font']
|
||||
test_font = 'TkTextFont'
|
||||
self.assertNotEqual(orig_font, test_font)
|
||||
|
||||
# Ensure code context is not active.
|
||||
if cc.context is not None:
|
||||
cc.toggle_code_context_event()
|
||||
|
||||
self.font_override = test_font
|
||||
# Nothing breaks or changes with inactive code context.
|
||||
cc.update_font(test_font)
|
||||
cc.update_font()
|
||||
|
||||
# Activate code context, but no change to font.
|
||||
# Activate code context, previous font change is immediately effective.
|
||||
cc.toggle_code_context_event()
|
||||
eq(cc.context['font'], save_font)
|
||||
# Call font update with the existing font.
|
||||
cc.update_font(save_font)
|
||||
eq(cc.context['font'], save_font)
|
||||
cc.toggle_code_context_event()
|
||||
|
||||
# Change text widget font and activate code context.
|
||||
cc.text['font'] = test_font
|
||||
cc.toggle_code_context_event(test_font)
|
||||
eq(cc.context['font'], test_font)
|
||||
|
||||
# Just call the font update.
|
||||
cc.update_font(save_font)
|
||||
eq(cc.context['font'], save_font)
|
||||
cc.text['font'] = save_font
|
||||
# Call the font update, change is picked up.
|
||||
self.font_override = orig_font
|
||||
cc.update_font()
|
||||
eq(cc.context['font'], orig_font)
|
||||
|
||||
def test_highlight_colors(self):
|
||||
eq = self.assertEqual
|
||||
cc = self.cc
|
||||
save_colors = dict(self.highlight_cfg)
|
||||
|
||||
orig_colors = dict(self.highlight_cfg)
|
||||
test_colors = {'background': '#222222', 'foreground': '#ffff00'}
|
||||
|
||||
def assert_colors_are_equal(colors):
|
||||
eq(cc.context['background'], colors['background'])
|
||||
eq(cc.context['foreground'], colors['foreground'])
|
||||
|
||||
# Ensure code context is not active.
|
||||
if cc.context:
|
||||
cc.toggle_code_context_event()
|
||||
|
||||
self.highlight_cfg = test_colors
|
||||
# Nothing breaks with inactive code context.
|
||||
cc.update_highlight_colors()
|
||||
|
||||
# Activate code context, but no change to colors.
|
||||
# Activate code context, previous colors change is immediately effective.
|
||||
cc.toggle_code_context_event()
|
||||
eq(cc.context['background'], save_colors['background'])
|
||||
eq(cc.context['foreground'], save_colors['foreground'])
|
||||
assert_colors_are_equal(test_colors)
|
||||
|
||||
# Call colors update, but no change to font.
|
||||
# Call colors update with no change to the configured colors.
|
||||
cc.update_highlight_colors()
|
||||
eq(cc.context['background'], save_colors['background'])
|
||||
eq(cc.context['foreground'], save_colors['foreground'])
|
||||
cc.toggle_code_context_event()
|
||||
assert_colors_are_equal(test_colors)
|
||||
|
||||
# Change colors and activate code context.
|
||||
self.highlight_cfg = test_colors
|
||||
cc.toggle_code_context_event()
|
||||
eq(cc.context['background'], test_colors['background'])
|
||||
eq(cc.context['foreground'], test_colors['foreground'])
|
||||
|
||||
# Change colors and call highlight colors update.
|
||||
self.highlight_cfg = save_colors
|
||||
# Call the colors update with code context active, change is picked up.
|
||||
self.highlight_cfg = orig_colors
|
||||
cc.update_highlight_colors()
|
||||
eq(cc.context['background'], save_colors['background'])
|
||||
eq(cc.context['foreground'], save_colors['foreground'])
|
||||
assert_colors_are_equal(orig_colors)
|
||||
|
||||
|
||||
class HelperFunctionText(unittest.TestCase):
|
||||
|
|
|
@ -0,0 +1,351 @@
|
|||
"""Test sidebar, coverage 93%"""
|
||||
from itertools import chain
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from test.support import requires
|
||||
import tkinter as tk
|
||||
|
||||
from idlelib.delegator import Delegator
|
||||
from idlelib.percolator import Percolator
|
||||
import idlelib.sidebar
|
||||
|
||||
|
||||
class Dummy_editwin:
|
||||
def __init__(self, text):
|
||||
self.text = text
|
||||
self.text_frame = self.text.master
|
||||
self.per = Percolator(text)
|
||||
self.undo = Delegator()
|
||||
self.per.insertfilter(self.undo)
|
||||
|
||||
def setvar(self, name, value):
|
||||
pass
|
||||
|
||||
def getlineno(self, index):
|
||||
return int(float(self.text.index(index)))
|
||||
|
||||
|
||||
class LineNumbersTest(unittest.TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
requires('gui')
|
||||
cls.root = tk.Tk()
|
||||
|
||||
cls.text_frame = tk.Frame(cls.root)
|
||||
cls.text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
cls.text_frame.rowconfigure(1, weight=1)
|
||||
cls.text_frame.columnconfigure(1, weight=1)
|
||||
|
||||
cls.text = tk.Text(cls.text_frame, width=80, height=24, wrap=tk.NONE)
|
||||
cls.text.grid(row=1, column=1, sticky=tk.NSEW)
|
||||
|
||||
cls.editwin = Dummy_editwin(cls.text)
|
||||
cls.editwin.vbar = tk.Scrollbar(cls.text_frame)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.editwin.per.close()
|
||||
cls.root.update()
|
||||
cls.root.destroy()
|
||||
del cls.text, cls.text_frame, cls.editwin, cls.root
|
||||
|
||||
def setUp(self):
|
||||
self.linenumber = idlelib.sidebar.LineNumbers(self.editwin)
|
||||
|
||||
self.highlight_cfg = {"background": '#abcdef',
|
||||
"foreground": '#123456'}
|
||||
orig_idleConf_GetHighlight = idlelib.sidebar.idleConf.GetHighlight
|
||||
def mock_idleconf_GetHighlight(theme, element):
|
||||
if element == 'linenumber':
|
||||
return self.highlight_cfg
|
||||
return orig_idleConf_GetHighlight(theme, element)
|
||||
GetHighlight_patcher = unittest.mock.patch.object(
|
||||
idlelib.sidebar.idleConf, 'GetHighlight', mock_idleconf_GetHighlight)
|
||||
GetHighlight_patcher.start()
|
||||
self.addCleanup(GetHighlight_patcher.stop)
|
||||
|
||||
self.font_override = 'TkFixedFont'
|
||||
def mock_idleconf_GetFont(root, configType, section):
|
||||
return self.font_override
|
||||
GetFont_patcher = unittest.mock.patch.object(
|
||||
idlelib.sidebar.idleConf, 'GetFont', mock_idleconf_GetFont)
|
||||
GetFont_patcher.start()
|
||||
self.addCleanup(GetFont_patcher.stop)
|
||||
|
||||
def tearDown(self):
|
||||
self.text.delete('1.0', 'end')
|
||||
|
||||
def get_selection(self):
|
||||
return tuple(map(str, self.text.tag_ranges('sel')))
|
||||
|
||||
def get_line_screen_position(self, line):
|
||||
bbox = self.linenumber.sidebar_text.bbox(f'{line}.end -1c')
|
||||
x = bbox[0] + 2
|
||||
y = bbox[1] + 2
|
||||
return x, y
|
||||
|
||||
def assert_state_disabled(self):
|
||||
state = self.linenumber.sidebar_text.config()['state']
|
||||
self.assertEqual(state[-1], tk.DISABLED)
|
||||
|
||||
def get_sidebar_text_contents(self):
|
||||
return self.linenumber.sidebar_text.get('1.0', tk.END)
|
||||
|
||||
def assert_sidebar_n_lines(self, n_lines):
|
||||
expected = '\n'.join(chain(map(str, range(1, n_lines + 1)), ['']))
|
||||
self.assertEqual(self.get_sidebar_text_contents(), expected)
|
||||
|
||||
def assert_text_equals(self, expected):
|
||||
return self.assertEqual(self.text.get('1.0', 'end'), expected)
|
||||
|
||||
def test_init_empty(self):
|
||||
self.assert_sidebar_n_lines(1)
|
||||
|
||||
def test_init_not_empty(self):
|
||||
self.text.insert('insert', 'foo bar\n'*3)
|
||||
self.assert_text_equals('foo bar\n'*3 + '\n')
|
||||
self.assert_sidebar_n_lines(4)
|
||||
|
||||
def test_toggle_linenumbering(self):
|
||||
self.assertEqual(self.linenumber.is_shown, False)
|
||||
self.linenumber.show_sidebar()
|
||||
self.assertEqual(self.linenumber.is_shown, True)
|
||||
self.linenumber.hide_sidebar()
|
||||
self.assertEqual(self.linenumber.is_shown, False)
|
||||
self.linenumber.hide_sidebar()
|
||||
self.assertEqual(self.linenumber.is_shown, False)
|
||||
self.linenumber.show_sidebar()
|
||||
self.assertEqual(self.linenumber.is_shown, True)
|
||||
self.linenumber.show_sidebar()
|
||||
self.assertEqual(self.linenumber.is_shown, True)
|
||||
|
||||
def test_insert(self):
|
||||
self.text.insert('insert', 'foobar')
|
||||
self.assert_text_equals('foobar\n')
|
||||
self.assert_sidebar_n_lines(1)
|
||||
self.assert_state_disabled()
|
||||
|
||||
self.text.insert('insert', '\nfoo')
|
||||
self.assert_text_equals('foobar\nfoo\n')
|
||||
self.assert_sidebar_n_lines(2)
|
||||
self.assert_state_disabled()
|
||||
|
||||
self.text.insert('insert', 'hello\n'*2)
|
||||
self.assert_text_equals('foobar\nfoohello\nhello\n\n')
|
||||
self.assert_sidebar_n_lines(4)
|
||||
self.assert_state_disabled()
|
||||
|
||||
self.text.insert('insert', '\nworld')
|
||||
self.assert_text_equals('foobar\nfoohello\nhello\n\nworld\n')
|
||||
self.assert_sidebar_n_lines(5)
|
||||
self.assert_state_disabled()
|
||||
|
||||
def test_delete(self):
|
||||
self.text.insert('insert', 'foobar')
|
||||
self.assert_text_equals('foobar\n')
|
||||
self.text.delete('1.1', '1.3')
|
||||
self.assert_text_equals('fbar\n')
|
||||
self.assert_sidebar_n_lines(1)
|
||||
self.assert_state_disabled()
|
||||
|
||||
self.text.insert('insert', 'foo\n'*2)
|
||||
self.assert_text_equals('fbarfoo\nfoo\n\n')
|
||||
self.assert_sidebar_n_lines(3)
|
||||
self.assert_state_disabled()
|
||||
|
||||
# Note: deleting up to "2.end" doesn't delete the final newline.
|
||||
self.text.delete('2.0', '2.end')
|
||||
self.assert_text_equals('fbarfoo\n\n\n')
|
||||
self.assert_sidebar_n_lines(3)
|
||||
self.assert_state_disabled()
|
||||
|
||||
self.text.delete('1.3', 'end')
|
||||
self.assert_text_equals('fba\n')
|
||||
self.assert_sidebar_n_lines(1)
|
||||
self.assert_state_disabled()
|
||||
|
||||
# Note: Text widgets always keep a single '\n' character at the end.
|
||||
self.text.delete('1.0', 'end')
|
||||
self.assert_text_equals('\n')
|
||||
self.assert_sidebar_n_lines(1)
|
||||
self.assert_state_disabled()
|
||||
|
||||
def test_sidebar_text_width(self):
|
||||
"""
|
||||
Test that linenumber text widget is always at the minimum
|
||||
width
|
||||
"""
|
||||
def get_width():
|
||||
return self.linenumber.sidebar_text.config()['width'][-1]
|
||||
|
||||
self.assert_sidebar_n_lines(1)
|
||||
self.assertEqual(get_width(), 1)
|
||||
|
||||
self.text.insert('insert', 'foo')
|
||||
self.assert_sidebar_n_lines(1)
|
||||
self.assertEqual(get_width(), 1)
|
||||
|
||||
self.text.insert('insert', 'foo\n'*8)
|
||||
self.assert_sidebar_n_lines(9)
|
||||
self.assertEqual(get_width(), 1)
|
||||
|
||||
self.text.insert('insert', 'foo\n')
|
||||
self.assert_sidebar_n_lines(10)
|
||||
self.assertEqual(get_width(), 2)
|
||||
|
||||
self.text.insert('insert', 'foo\n')
|
||||
self.assert_sidebar_n_lines(11)
|
||||
self.assertEqual(get_width(), 2)
|
||||
|
||||
self.text.delete('insert -1l linestart', 'insert linestart')
|
||||
self.assert_sidebar_n_lines(10)
|
||||
self.assertEqual(get_width(), 2)
|
||||
|
||||
self.text.delete('insert -1l linestart', 'insert linestart')
|
||||
self.assert_sidebar_n_lines(9)
|
||||
self.assertEqual(get_width(), 1)
|
||||
|
||||
self.text.insert('insert', 'foo\n'*90)
|
||||
self.assert_sidebar_n_lines(99)
|
||||
self.assertEqual(get_width(), 2)
|
||||
|
||||
self.text.insert('insert', 'foo\n')
|
||||
self.assert_sidebar_n_lines(100)
|
||||
self.assertEqual(get_width(), 3)
|
||||
|
||||
self.text.insert('insert', 'foo\n')
|
||||
self.assert_sidebar_n_lines(101)
|
||||
self.assertEqual(get_width(), 3)
|
||||
|
||||
self.text.delete('insert -1l linestart', 'insert linestart')
|
||||
self.assert_sidebar_n_lines(100)
|
||||
self.assertEqual(get_width(), 3)
|
||||
|
||||
self.text.delete('insert -1l linestart', 'insert linestart')
|
||||
self.assert_sidebar_n_lines(99)
|
||||
self.assertEqual(get_width(), 2)
|
||||
|
||||
self.text.delete('50.0 -1c', 'end -1c')
|
||||
self.assert_sidebar_n_lines(49)
|
||||
self.assertEqual(get_width(), 2)
|
||||
|
||||
self.text.delete('5.0 -1c', 'end -1c')
|
||||
self.assert_sidebar_n_lines(4)
|
||||
self.assertEqual(get_width(), 1)
|
||||
|
||||
# Note: Text widgets always keep a single '\n' character at the end.
|
||||
self.text.delete('1.0', 'end -1c')
|
||||
self.assert_sidebar_n_lines(1)
|
||||
self.assertEqual(get_width(), 1)
|
||||
|
||||
def test_click_selection(self):
|
||||
self.linenumber.show_sidebar()
|
||||
self.text.insert('1.0', 'one\ntwo\nthree\nfour\n')
|
||||
self.root.update()
|
||||
|
||||
# Click on the second line.
|
||||
x, y = self.get_line_screen_position(2)
|
||||
self.linenumber.sidebar_text.event_generate('<Button-1>', x=x, y=y)
|
||||
self.linenumber.sidebar_text.update()
|
||||
self.root.update()
|
||||
|
||||
self.assertEqual(self.get_selection(), ('2.0', '3.0'))
|
||||
|
||||
def test_drag_selection(self):
|
||||
self.linenumber.show_sidebar()
|
||||
self.text.insert('1.0', 'one\ntwo\nthree\nfour\n')
|
||||
self.root.update()
|
||||
|
||||
# Drag from the first line to the third line.
|
||||
start_x, start_y = self.get_line_screen_position(1)
|
||||
end_x, end_y = self.get_line_screen_position(3)
|
||||
self.linenumber.sidebar_text.event_generate('<Button-1>',
|
||||
x=start_x, y=start_y)
|
||||
self.linenumber.sidebar_text.event_generate('<B1-Motion>',
|
||||
x=start_x, y=start_y)
|
||||
self.linenumber.sidebar_text.event_generate('<B1-Motion>',
|
||||
x=end_x, y=end_y)
|
||||
self.linenumber.sidebar_text.event_generate('<ButtonRelease-1>',
|
||||
x=end_x, y=end_y)
|
||||
self.root.update()
|
||||
|
||||
self.assertEqual(self.get_selection(), ('1.0', '4.0'))
|
||||
|
||||
def test_scroll(self):
|
||||
self.linenumber.show_sidebar()
|
||||
self.text.insert('1.0', 'line\n' * 100)
|
||||
self.root.update()
|
||||
|
||||
# Scroll down 10 lines.
|
||||
self.text.yview_scroll(10, 'unit')
|
||||
self.root.update()
|
||||
self.assertEqual(self.text.index('@0,0'), '11.0')
|
||||
self.assertEqual(self.linenumber.sidebar_text.index('@0,0'), '11.0')
|
||||
|
||||
# Generate a mouse-wheel event and make sure it scrolled up or down.
|
||||
# The meaning of the "delta" is OS-dependant, so this just checks for
|
||||
# any change.
|
||||
self.linenumber.sidebar_text.event_generate('<MouseWheel>',
|
||||
x=0, y=0,
|
||||
delta=10)
|
||||
self.root.update()
|
||||
self.assertNotEqual(self.text.index('@0,0'), '11.0')
|
||||
self.assertNotEqual(self.linenumber.sidebar_text.index('@0,0'), '11.0')
|
||||
|
||||
def test_font(self):
|
||||
ln = self.linenumber
|
||||
|
||||
orig_font = ln.sidebar_text['font']
|
||||
test_font = 'TkTextFont'
|
||||
self.assertNotEqual(orig_font, test_font)
|
||||
|
||||
# Ensure line numbers aren't shown.
|
||||
ln.hide_sidebar()
|
||||
|
||||
self.font_override = test_font
|
||||
# Nothing breaks when line numbers aren't shown.
|
||||
ln.update_font()
|
||||
|
||||
# Activate line numbers, previous font change is immediately effective.
|
||||
ln.show_sidebar()
|
||||
self.assertEqual(ln.sidebar_text['font'], test_font)
|
||||
|
||||
# Call the font update with line numbers shown, change is picked up.
|
||||
self.font_override = orig_font
|
||||
ln.update_font()
|
||||
self.assertEqual(ln.sidebar_text['font'], orig_font)
|
||||
|
||||
def test_highlight_colors(self):
|
||||
ln = self.linenumber
|
||||
|
||||
orig_colors = dict(self.highlight_cfg)
|
||||
test_colors = {'background': '#222222', 'foreground': '#ffff00'}
|
||||
|
||||
def assert_colors_are_equal(colors):
|
||||
self.assertEqual(ln.sidebar_text['background'], colors['background'])
|
||||
self.assertEqual(ln.sidebar_text['foreground'], colors['foreground'])
|
||||
|
||||
# Ensure line numbers aren't shown.
|
||||
ln.hide_sidebar()
|
||||
|
||||
self.highlight_cfg = test_colors
|
||||
# Nothing breaks with inactive code context.
|
||||
ln.update_colors()
|
||||
|
||||
# Show line numbers, previous colors change is immediately effective.
|
||||
ln.show_sidebar()
|
||||
assert_colors_are_equal(test_colors)
|
||||
|
||||
# Call colors update with no change to the configured colors.
|
||||
ln.update_colors()
|
||||
assert_colors_are_equal(test_colors)
|
||||
|
||||
# Call the colors update with line numbers shown, change is picked up.
|
||||
self.highlight_cfg = orig_colors
|
||||
ln.update_colors()
|
||||
assert_colors_are_equal(orig_colors)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
|
@ -100,7 +100,8 @@ menudefs = [
|
|||
('Configure _IDLE', '<<open-config-dialog>>'),
|
||||
None,
|
||||
('Show _Code Context', '<<toggle-code-context>>'),
|
||||
('Zoom Height', '<<zoom-height>>'),
|
||||
('Show _Line Numbers', '<<toggle-line-numbers>>'),
|
||||
('_Zoom Height', '<<zoom-height>>'),
|
||||
]),
|
||||
|
||||
('window', [
|
||||
|
|
|
@ -74,13 +74,11 @@ class OutputWindow(EditorWindow):
|
|||
("Go to file/line", "<<goto-file-line>>", None),
|
||||
]
|
||||
|
||||
allow_codecontext = False
|
||||
allow_code_context = False
|
||||
|
||||
def __init__(self, *args):
|
||||
EditorWindow.__init__(self, *args)
|
||||
self.text.bind("<<goto-file-line>>", self.goto_file_line)
|
||||
self.text.unbind("<<toggle-code-context>>")
|
||||
self.update_menu_state('options', '*Code Context', 'disabled')
|
||||
|
||||
# Customize EditorWindow
|
||||
def ispythonsource(self, filename):
|
||||
|
|
|
@ -861,6 +861,8 @@ class PyShell(OutputWindow):
|
|||
("Squeeze", "<<squeeze-current-text>>"),
|
||||
]
|
||||
|
||||
allow_line_numbers = False
|
||||
|
||||
# New classes
|
||||
from idlelib.history import History
|
||||
|
||||
|
|
|
@ -0,0 +1,324 @@
|
|||
"""Line numbering implementation for IDLE as an extension.
|
||||
Includes BaseSideBar which can be extended for other sidebar based extensions
|
||||
"""
|
||||
import functools
|
||||
import itertools
|
||||
|
||||
import tkinter as tk
|
||||
from idlelib.config import idleConf
|
||||
from idlelib.delegator import Delegator
|
||||
|
||||
|
||||
def get_end_linenumber(text):
|
||||
"""Utility to get the last line's number in a Tk text widget."""
|
||||
return int(float(text.index('end-1c')))
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class BaseSideBar:
|
||||
"""
|
||||
The base class for extensions which require a sidebar.
|
||||
"""
|
||||
def __init__(self, editwin):
|
||||
self.editwin = editwin
|
||||
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=0, pady=pady,
|
||||
borderwidth=0, highlightthickness=0)
|
||||
self.sidebar_text.config(state=tk.DISABLED)
|
||||
self.text['yscrollcommand'] = self.redirect_yscroll_event
|
||||
self.update_font()
|
||||
self.update_colors()
|
||||
|
||||
self.is_shown = False
|
||||
|
||||
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
|
||||
|
||||
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'])
|
||||
|
||||
def _update_colors(self, foreground, background):
|
||||
self.sidebar_text.config(
|
||||
fg=foreground, bg=background,
|
||||
selectforeground=foreground, selectbackground=background,
|
||||
inactiveselectbackground=background,
|
||||
)
|
||||
|
||||
def show_sidebar(self):
|
||||
if not self.is_shown:
|
||||
self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW)
|
||||
self.is_shown = True
|
||||
|
||||
def hide_sidebar(self):
|
||||
if self.is_shown:
|
||||
self.sidebar_text.grid_forget()
|
||||
self.is_shown = False
|
||||
|
||||
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'
|
||||
|
||||
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'
|
||||
|
||||
|
||||
class EndLineDelegator(Delegator):
|
||||
"""Generate callbacks with the current end line number after
|
||||
insert or delete operations"""
|
||||
def __init__(self, changed_callback):
|
||||
"""
|
||||
changed_callback - Callable, will be called after insert
|
||||
or delete operations with the current
|
||||
end line number.
|
||||
"""
|
||||
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.
|
||||
end_line_delegator.setdelegate(self.editwin.undo.delegate)
|
||||
self.editwin.undo.setdelegate(end_line_delegator)
|
||||
# Reset the delegator caches of the delegators "above" the
|
||||
# end line delegator we just inserted.
|
||||
delegator = self.editwin.per.top
|
||||
while delegator is not end_line_delegator:
|
||||
delegator.resetcache()
|
||||
delegator = delegator.delegate
|
||||
|
||||
self.is_shown = False
|
||||
|
||||
def bind_events(self):
|
||||
# Ensure focus is always redirected to the main editor text widget.
|
||||
self.sidebar_text.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)
|
||||
|
||||
# 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.sidebar_text.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 = None
|
||||
def b1_mousedown_handler(event):
|
||||
# select the entire line
|
||||
lineno = self.editwin.getlineno(f"@0,{event.y}")
|
||||
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")
|
||||
|
||||
# remember this line in case this is the beginning of dragging
|
||||
nonlocal start_line
|
||||
start_line = lineno
|
||||
self.sidebar_text.bind('<Button-1>', b1_mousedown_handler)
|
||||
|
||||
# These are set by b1_motion_handler() and read by selection_handler();
|
||||
# see below. 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 drag_update_selection_and_insert_mark(y_coord):
|
||||
"""Helper function for drag and selection event handlers."""
|
||||
lineno = self.editwin.getlineno(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):
|
||||
nonlocal last_y
|
||||
nonlocal last_yview
|
||||
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):
|
||||
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)
|
||||
|
||||
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'])
|
||||
|
||||
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)
|
||||
|
||||
self.sidebar_text.config(state=tk.NORMAL)
|
||||
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.sidebar_text.config(state=tk.DISABLED)
|
||||
|
||||
self.prev_end = end
|
||||
|
||||
|
||||
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)
|
|
@ -0,0 +1,4 @@
|
|||
Add optional line numbers for IDLE editor windows. Windows
|
||||
open without line numbers unless set otherwise in the General
|
||||
tab of the configuration dialog.
|
||||
|
Loading…
Reference in New Issue