Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 7123ea0

Browse files
authoredJul 23, 2019
bpo-17535: IDLE editor line numbers (pythonGH-14030)
1 parent 1ebee37 commit 7123ea0

18 files changed

+891
-80
lines changed
 

‎Doc/library/idle.rst

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -290,22 +290,31 @@ Options menu (Shell and Editor)
290290
Configure IDLE
291291
Open a configuration dialog and change preferences for the following:
292292
fonts, indentation, keybindings, text color themes, startup windows and
293-
size, additional help sources, and extensions. On macOS, open the
293+
size, additional help sources, and extensions. On macOS, open the
294294
configuration dialog by selecting Preferences in the application
295-
menu. For more, see
295+
menu. For more details, see
296296
:ref:`Setting preferences <preferences>` under Help and preferences.
297297

298+
Most configuration options apply to all windows or all future windows.
299+
The option items below only apply to the active window.
300+
298301
Show/Hide Code Context (Editor Window only)
299302
Open a pane at the top of the edit window which shows the block context
300303
of the code which has scrolled above the top of the window. See
301-
:ref:`Code Context <code-context>` in the Editing and Navigation section below.
304+
:ref:`Code Context <code-context>` in the Editing and Navigation section
305+
below.
306+
307+
Show/Hide Line Numbers (Editor Window only)
308+
Open a column to the left of the edit window which shows the number
309+
of each line of text. The default is off, which may be changed in the
310+
preferences (see :ref:`Setting preferences <preferences>`).
302311

303312
Zoom/Restore Height
304313
Toggles the window between normal size and maximum height. The initial size
305314
defaults to 40 lines by 80 chars unless changed on the General tab of the
306315
Configure IDLE dialog. The maximum height for a screen is determined by
307316
momentarily maximizing a window the first time one is zoomed on the screen.
308-
Changing screen settings may invalidate the saved height. This toogle has
317+
Changing screen settings may invalidate the saved height. This toggle has
309318
no effect when a window is maximized.
310319

311320
Window menu (Shell and Editor)

‎Doc/whatsnew/3.7.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,13 @@ by right-clicking the button. (Contributed by Tal Einat in :issue:`1529353`.)
10171017

10181018
The changes above have been backported to 3.6 maintenance releases.
10191019

1020+
New in 3.7.5:
1021+
1022+
Add optional line numbers for IDLE editor windows. Windows
1023+
open without line numbers unless set otherwise in the General
1024+
tab of the configuration dialog.
1025+
(Contributed by Tal Einat and Saimadhav Heblikar in :issue:`17535`.)
1026+
10201027

10211028
importlib
10221029
---------

‎Doc/whatsnew/3.8.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,11 @@ for certain types of invalid or corrupt gzip files.
515515
idlelib and IDLE
516516
----------------
517517

518+
Add optional line numbers for IDLE editor windows. Windows
519+
open without line numbers unless set otherwise in the General
520+
tab of the configuration dialog.
521+
(Contributed by Tal Einat and Saimadhav Heblikar in :issue:`17535`.)
522+
518523
Output over N lines (50 by default) is squeezed down to a button.
519524
N can be changed in the PyShell section of the General page of the
520525
Settings dialog. Fewer, but possibly extra long, lines can be squeezed by

‎Lib/idlelib/codecontext.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from sys import maxsize as INFINITY
1414

1515
import tkinter
16-
from tkinter.constants import TOP, X, SUNKEN
16+
from tkinter.constants import NSEW, SUNKEN
1717

1818
from idlelib.config import idleConf
1919

@@ -67,6 +67,7 @@ def __init__(self, editwin):
6767

6868
def _reset(self):
6969
self.context = None
70+
self.cell00 = None
7071
self.t1 = None
7172
self.topvisible = 1
7273
self.info = [(0, -1, "", False)]
@@ -105,25 +106,37 @@ def toggle_code_context_event(self, event=None):
105106
padx = 0
106107
border = 0
107108
for widget in widgets:
108-
padx += widget.tk.getint(widget.pack_info()['padx'])
109+
info = (widget.grid_info()
110+
if widget is self.editwin.text
111+
else widget.pack_info())
112+
padx += widget.tk.getint(info['padx'])
109113
padx += widget.tk.getint(widget.cget('padx'))
110114
border += widget.tk.getint(widget.cget('border'))
111115
self.context = tkinter.Text(
112-
self.editwin.top, font=self.text['font'],
116+
self.editwin.text_frame,
113117
height=1,
114118
width=1, # Don't request more than we get.
119+
highlightthickness=0,
115120
padx=padx, border=border, relief=SUNKEN, state='disabled')
121+
self.update_font()
116122
self.update_highlight_colors()
117123
self.context.bind('<ButtonRelease-1>', self.jumptoline)
118124
# Get the current context and initiate the recurring update event.
119125
self.timer_event()
120-
# Pack the context widget before and above the text_frame widget,
121-
# thus ensuring that it will appear directly above text_frame.
122-
self.context.pack(side=TOP, fill=X, expand=False,
123-
before=self.editwin.text_frame)
126+
# Grid the context widget above the text widget.
127+
self.context.grid(row=0, column=1, sticky=NSEW)
128+
129+
line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(),
130+
'linenumber')
131+
self.cell00 = tkinter.Frame(self.editwin.text_frame,
132+
bg=line_number_colors['background'])
133+
self.cell00.grid(row=0, column=0, sticky=NSEW)
124134
menu_status = 'Hide'
125135
else:
126136
self.context.destroy()
137+
self.context = None
138+
self.cell00.destroy()
139+
self.cell00 = None
127140
self.text.after_cancel(self.t1)
128141
self._reset()
129142
menu_status = 'Show'
@@ -221,8 +234,9 @@ def timer_event(self):
221234
self.update_code_context()
222235
self.t1 = self.text.after(self.UPDATEINTERVAL, self.timer_event)
223236

224-
def update_font(self, font):
237+
def update_font(self):
225238
if self.context is not None:
239+
font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
226240
self.context['font'] = font
227241

228242
def update_highlight_colors(self):
@@ -231,6 +245,11 @@ def update_highlight_colors(self):
231245
self.context['background'] = colors['background']
232246
self.context['foreground'] = colors['foreground']
233247

248+
if self.cell00 is not None:
249+
line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(),
250+
'linenumber')
251+
self.cell00.config(bg=line_number_colors['background'])
252+
234253

235254
CodeContext.reload()
236255

‎Lib/idlelib/config-highlight.def

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ hit-foreground= #ffffff
2222
hit-background= #000000
2323
error-foreground= #000000
2424
error-background= #ff7777
25+
context-foreground= #000000
26+
context-background= lightgray
27+
linenumber-foreground= gray
28+
linenumber-background= #ffffff
2529
#cursor (only foreground can be set, restart IDLE)
2630
cursor-foreground= black
2731
#shell window
@@ -31,8 +35,6 @@ stderr-foreground= red
3135
stderr-background= #ffffff
3236
console-foreground= #770000
3337
console-background= #ffffff
34-
context-foreground= #000000
35-
context-background= lightgray
3638

3739
[IDLE New]
3840
normal-foreground= #000000
@@ -55,6 +57,10 @@ hit-foreground= #ffffff
5557
hit-background= #000000
5658
error-foreground= #000000
5759
error-background= #ff7777
60+
context-foreground= #000000
61+
context-background= lightgray
62+
linenumber-foreground= gray
63+
linenumber-background= #ffffff
5864
#cursor (only foreground can be set, restart IDLE)
5965
cursor-foreground= black
6066
#shell window
@@ -64,8 +70,6 @@ stderr-foreground= red
6470
stderr-background= #ffffff
6571
console-foreground= #770000
6672
console-background= #ffffff
67-
context-foreground= #000000
68-
context-background= lightgray
6973

7074
[IDLE Dark]
7175
comment-foreground = #dd0000
@@ -97,3 +101,5 @@ comment-background = #002240
97101
break-foreground = #FFFFFF
98102
context-foreground= #ffffff
99103
context-background= #454545
104+
linenumber-foreground= gray
105+
linenumber-background= #002240

‎Lib/idlelib/config-main.def

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
# Additional help sources are listed in the [HelpFiles] section below
3737
# and should be viewable by a web browser (or the Windows Help viewer in
3838
# the case of .chm files). These sources will be listed on the Help
39-
# menu. The pattern, and two examples, are
39+
# menu. The pattern, and two examples, are:
4040
#
4141
# <sequence_number = menu item;/path/to/help/source>
4242
# 1 = IDLE;C:/Programs/Python36/Lib/idlelib/help.html
@@ -46,7 +46,7 @@
4646
# platform specific because of path separators, drive specs etc.
4747
#
4848
# The default files should not be edited except to add new sections to
49-
# config-extensions.def for added extensions . The user files should be
49+
# config-extensions.def for added extensions. The user files should be
5050
# modified through the Settings dialog.
5151

5252
[General]
@@ -65,6 +65,7 @@ font= TkFixedFont
6565
font-size= 10
6666
font-bold= 0
6767
encoding= none
68+
line-numbers-default= 0
6869

6970
[PyShell]
7071
auto-squeeze-min-lines= 50

‎Lib/idlelib/config.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,10 @@ def GetThemeDict(self, type, themeName):
319319
'hit-background':'#000000',
320320
'error-foreground':'#ffffff',
321321
'error-background':'#000000',
322+
'context-foreground':'#000000',
323+
'context-background':'#ffffff',
324+
'linenumber-foreground':'#000000',
325+
'linenumber-background':'#ffffff',
322326
#cursor (only foreground can be set)
323327
'cursor-foreground':'#000000',
324328
#shell window
@@ -328,11 +332,11 @@ def GetThemeDict(self, type, themeName):
328332
'stderr-background':'#ffffff',
329333
'console-foreground':'#000000',
330334
'console-background':'#ffffff',
331-
'context-foreground':'#000000',
332-
'context-background':'#ffffff',
333335
}
334336
for element in theme:
335-
if not cfgParser.has_option(themeName, element):
337+
if not (cfgParser.has_option(themeName, element) or
338+
# Skip warning for new elements.
339+
element.startswith(('context-', 'linenumber-'))):
336340
# Print warning that will return a default color
337341
warning = ('\n Warning: config.IdleConf.GetThemeDict'
338342
' -\n problem retrieving theme element %r'

‎Lib/idlelib/configdialog.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -819,6 +819,7 @@ def create_page_highlight(self):
819819
'Shell Error Text': ('error', '12'),
820820
'Shell Stdout Text': ('stdout', '13'),
821821
'Shell Stderr Text': ('stderr', '14'),
822+
'Line Number': ('linenumber', '16'),
822823
}
823824
self.builtin_name = tracers.add(
824825
StringVar(self), self.var_changed_builtin_name)
@@ -866,6 +867,11 @@ def create_page_highlight(self):
866867
('stderr', 'stderr'), ('\n\n', 'normal'))
867868
for texttag in text_and_tags:
868869
text.insert(END, texttag[0], texttag[1])
870+
n_lines = len(text.get('1.0', END).splitlines())
871+
for lineno in range(1, n_lines + 1):
872+
text.insert(f'{lineno}.0',
873+
f'{lineno:{len(str(n_lines))}d} ',
874+
'linenumber')
869875
for element in self.theme_elements:
870876
def tem(event, elem=element):
871877
# event.widget.winfo_top_level().highlight_target.set(elem)
@@ -1827,6 +1833,9 @@ def create_page_general(self):
18271833
frame_format: Frame
18281834
format_width_title: Label
18291835
(*)format_width_int: Entry - format_width
1836+
frame_line_numbers_default: Frame
1837+
line_numbers_default_title: Label
1838+
(*)line_numbers_default_bool: Checkbutton - line_numbers_default
18301839
frame_context: Frame
18311840
context_title: Label
18321841
(*)context_int: Entry - context_lines
@@ -1866,6 +1875,9 @@ def create_page_general(self):
18661875
IntVar(self), ('main', 'General', 'autosave'))
18671876
self.format_width = tracers.add(
18681877
StringVar(self), ('extensions', 'FormatParagraph', 'max-width'))
1878+
self.line_numbers_default = tracers.add(
1879+
BooleanVar(self),
1880+
('main', 'EditorWindow', 'line-numbers-default'))
18691881
self.context_lines = tracers.add(
18701882
StringVar(self), ('extensions', 'CodeContext', 'maxlines'))
18711883

@@ -1944,6 +1956,14 @@ def create_page_general(self):
19441956
validatecommand=self.digits_only, validate='key',
19451957
)
19461958

1959+
frame_line_numbers_default = Frame(frame_editor, borderwidth=0)
1960+
line_numbers_default_title = Label(
1961+
frame_line_numbers_default, text='Show line numbers in new windows')
1962+
self.line_numbers_default_bool = Checkbutton(
1963+
frame_line_numbers_default,
1964+
variable=self.line_numbers_default,
1965+
width=1)
1966+
19471967
frame_context = Frame(frame_editor, borderwidth=0)
19481968
context_title = Label(frame_context, text='Max Context Lines :')
19491969
self.context_int = Entry(
@@ -2021,6 +2041,10 @@ def create_page_general(self):
20212041
frame_format.pack(side=TOP, padx=5, pady=0, fill=X)
20222042
format_width_title.pack(side=LEFT, anchor=W, padx=5, pady=5)
20232043
self.format_width_int.pack(side=TOP, padx=10, pady=5)
2044+
# frame_line_numbers_default.
2045+
frame_line_numbers_default.pack(side=TOP, padx=5, pady=0, fill=X)
2046+
line_numbers_default_title.pack(side=LEFT, anchor=W, padx=5, pady=5)
2047+
self.line_numbers_default_bool.pack(side=LEFT, padx=5, pady=5)
20242048
# frame_context.
20252049
frame_context.pack(side=TOP, padx=5, pady=0, fill=X)
20262050
context_title.pack(side=LEFT, anchor=W, padx=5, pady=5)
@@ -2063,6 +2087,8 @@ def load_general_cfg(self):
20632087
'main', 'General', 'autosave', default=0, type='bool'))
20642088
self.format_width.set(idleConf.GetOption(
20652089
'extensions', 'FormatParagraph', 'max-width', type='int'))
2090+
self.line_numbers_default.set(idleConf.GetOption(
2091+
'main', 'EditorWindow', 'line-numbers-default', type='bool'))
20662092
self.context_lines.set(idleConf.GetOption(
20672093
'extensions', 'CodeContext', 'maxlines', type='int'))
20682094

‎Lib/idlelib/editor.py

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class EditorWindow(object):
5353
from idlelib.autoexpand import AutoExpand
5454
from idlelib.calltip import Calltip
5555
from idlelib.codecontext import CodeContext
56+
from idlelib.sidebar import LineNumbers
5657
from idlelib.format import FormatParagraph, FormatRegion, Indents, Rstrip
5758
from idlelib.parenmatch import ParenMatch
5859
from idlelib.squeezer import Squeezer
@@ -61,7 +62,8 @@ class EditorWindow(object):
6162
filesystemencoding = sys.getfilesystemencoding() # for file names
6263
help_url = None
6364

64-
allow_codecontext = True
65+
allow_code_context = True
66+
allow_line_numbers = True
6567

6668
def __init__(self, flist=None, filename=None, key=None, root=None):
6769
# Delay import: runscript imports pyshell imports EditorWindow.
@@ -198,12 +200,14 @@ def __init__(self, flist=None, filename=None, key=None, root=None):
198200
text.bind("<<open-turtle-demo>>", self.open_turtle_demo)
199201

200202
self.set_status_bar()
203+
text_frame.pack(side=LEFT, fill=BOTH, expand=1)
204+
text_frame.rowconfigure(1, weight=1)
205+
text_frame.columnconfigure(1, weight=1)
201206
vbar['command'] = self.handle_yview
202-
vbar.pack(side=RIGHT, fill=Y)
207+
vbar.grid(row=1, column=2, sticky=NSEW)
203208
text['yscrollcommand'] = vbar.set
204209
text['font'] = idleConf.GetFont(self.root, 'main', 'EditorWindow')
205-
text_frame.pack(side=LEFT, fill=BOTH, expand=1)
206-
text.pack(side=TOP, fill=BOTH, expand=1)
210+
text.grid(row=1, column=1, sticky=NSEW)
207211
text.focus_set()
208212

209213
# usetabs true -> literal tab characters are used by indent and
@@ -250,7 +254,8 @@ def __init__(self, flist=None, filename=None, key=None, root=None):
250254
self.good_load = False
251255
self.set_indentation_params(False)
252256
self.color = None # initialized below in self.ResetColorizer
253-
self.codecontext = None
257+
self.code_context = None # optionally initialized later below
258+
self.line_numbers = None # optionally initialized later below
254259
if filename:
255260
if os.path.exists(filename) and not os.path.isdir(filename):
256261
if io.loadfile(filename):
@@ -316,10 +321,20 @@ def __init__(self, flist=None, filename=None, key=None, root=None):
316321
text.bind("<<refresh-calltip>>", ctip.refresh_calltip_event)
317322
text.bind("<<force-open-calltip>>", ctip.force_open_calltip_event)
318323
text.bind("<<zoom-height>>", self.ZoomHeight(self).zoom_height_event)
319-
if self.allow_codecontext:
320-
self.codecontext = self.CodeContext(self)
324+
if self.allow_code_context:
325+
self.code_context = self.CodeContext(self)
321326
text.bind("<<toggle-code-context>>",
322-
self.codecontext.toggle_code_context_event)
327+
self.code_context.toggle_code_context_event)
328+
else:
329+
self.update_menu_state('options', '*Code Context', 'disabled')
330+
if self.allow_line_numbers:
331+
self.line_numbers = self.LineNumbers(self)
332+
if idleConf.GetOption('main', 'EditorWindow',
333+
'line-numbers-default', type='bool'):
334+
self.toggle_line_numbers_event()
335+
text.bind("<<toggle-line-numbers>>", self.toggle_line_numbers_event)
336+
else:
337+
self.update_menu_state('options', '*Line Numbers', 'disabled')
323338

324339
def _filename_to_unicode(self, filename):
325340
"""Return filename as BMP unicode so displayable in Tk."""
@@ -779,8 +794,11 @@ def ResetColorizer(self):
779794
self._addcolorizer()
780795
EditorWindow.color_config(self.text)
781796

782-
if self.codecontext is not None:
783-
self.codecontext.update_highlight_colors()
797+
if self.code_context is not None:
798+
self.code_context.update_highlight_colors()
799+
800+
if self.line_numbers is not None:
801+
self.line_numbers.update_colors()
784802

785803
IDENTCHARS = string.ascii_letters + string.digits + "_"
786804

@@ -799,11 +817,16 @@ def ResetFont(self):
799817
"Update the text widgets' font if it is changed"
800818
# Called from configdialog.py
801819

802-
new_font = idleConf.GetFont(self.root, 'main', 'EditorWindow')
803820
# Update the code context widget first, since its height affects
804821
# the height of the text widget. This avoids double re-rendering.
805-
if self.codecontext is not None:
806-
self.codecontext.update_font(new_font)
822+
if self.code_context is not None:
823+
self.code_context.update_font()
824+
# Next, update the line numbers widget, since its width affects
825+
# the width of the text widget.
826+
if self.line_numbers is not None:
827+
self.line_numbers.update_font()
828+
# Finally, update the main text widget.
829+
new_font = idleConf.GetFont(self.root, 'main', 'EditorWindow')
807830
self.text['font'] = new_font
808831

809832
def RemoveKeybindings(self):
@@ -1467,6 +1490,19 @@ def guess_indent(self):
14671490
indentsmall = indentlarge = 0
14681491
return indentlarge - indentsmall
14691492

1493+
def toggle_line_numbers_event(self, event=None):
1494+
if self.line_numbers is None:
1495+
return
1496+
1497+
if self.line_numbers.is_shown:
1498+
self.line_numbers.hide_sidebar()
1499+
menu_label = "Show"
1500+
else:
1501+
self.line_numbers.show_sidebar()
1502+
menu_label = "Hide"
1503+
self.update_menu_label(menu='options', index='*Line Numbers',
1504+
label=f'{menu_label} Line Numbers')
1505+
14701506
# "line.col" -> line, as an int
14711507
def index2line(index):
14721508
return int(float(index))

‎Lib/idlelib/help.html

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -271,10 +271,15 @@ <h3>Options menu (Shell and Editor)<a class="headerlink" href="#options-menu-she
271271
size, additional help sources, and extensions. On macOS, open the
272272
configuration dialog by selecting Preferences in the application
273273
menu. For more, see
274-
<a class="reference internal" href="#preferences"><span class="std std-ref">Setting preferences</span></a> under Help and preferences.</dd>
274+
<a class="reference internal" href="#preferences"><span class="std std-ref">Setting preferences</span></a> under Help and preferences.
275+
Most configuration options apply to all windows or all future windows.
276+
The option items below only apply to the active window.</dd>
275277
<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
276278
of the code which has scrolled above the top of the window. See
277279
<a class="reference internal" href="#code-context"><span class="std std-ref">Code Context</span></a> in the Editing and Navigation section below.</dd>
280+
<dt>Line Numbers (Editor Window only)</dt><dd>Open a column to the left of the edit window which shows the linenumber
281+
of each line of text. The default is off unless configured on
282+
(see <a class="reference internal" href="#preferences"><span class="std std-ref">Setting preferences</span></a>).</dd>
278283
<dt>Zoom/Restore Height</dt><dd>Toggles the window between normal size and maximum height. The initial size
279284
defaults to 40 lines by 80 chars unless changed on the General tab of the
280285
Configure IDLE dialog. The maximum height for a screen is determined by
@@ -607,7 +612,7 @@ <h3>Running user code<a class="headerlink" href="#running-user-code" title="Perm
607612
will then be attached to that window for input and output.</p>
608613
<p>The IDLE code running in the execution process adds frames to the call stack
609614
that would not be there otherwise. IDLE wraps <code class="docutils literal notranslate"><span class="pre">sys.getrecursionlimit</span></code> and
610-
<code class="docutils literal notranslate"><span class="pre">sys.setrecursionlimit</span></code> to reduce their visibility.</p>
615+
<code class="docutils literal notranslate"><span class="pre">sys.setrecursionlimit</span></code> to reduce the effect of the additional stack frames.</p>
611616
<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>,
612617
IDLE’s changes are lost and input from the keyboard and output to the screen
613618
will not work correctly.</p>
@@ -895,7 +900,7 @@ <h3>Navigation</h3>
895900
<br />
896901
<br />
897902

898-
Last updated on Jul 04, 2019.
903+
Last updated on Jul 23, 2019.
899904
<a href="https://docs.python.org/3/bugs.html">Found a bug</a>?
900905
<br />
901906

‎Lib/idlelib/idle_test/htest.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def _wrapper(parent): # htest #
6767

6868
import idlelib.pyshell # Set Windows DPI awareness before Tk().
6969
from importlib import import_module
70+
import textwrap
7071
import tkinter as tk
7172
from tkinter.ttk import Scrollbar
7273
tk.NoDefaultRoot()
@@ -205,6 +206,19 @@ def _wrapper(parent): # htest #
205206
"Check that changes were saved by opening the file elsewhere."
206207
}
207208

209+
_linenumbers_drag_scrolling_spec = {
210+
'file': 'sidebar',
211+
'kwds': {},
212+
'msg': textwrap.dedent("""\
213+
Click on the line numbers and drag down below the edge of the
214+
window, moving the mouse a bit and then leaving it there for a while.
215+
The text and line numbers should gradually scroll down, with the
216+
selection updated continuously.
217+
Do the same as above, dragging to above the window. The text and line
218+
numbers should gradually scroll up, with the selection updated
219+
continuously."""),
220+
}
221+
208222
_multi_call_spec = {
209223
'file': 'multicall',
210224
'kwds': {},

‎Lib/idlelib/idle_test/test_codecontext.py

Lines changed: 38 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import unittest
55
import unittest.mock
66
from test.support import requires
7-
from tkinter import Tk, Frame, Text, TclError
7+
from tkinter import NSEW, Tk, Frame, Text, TclError
88

99
from unittest import mock
1010
import re
@@ -62,7 +62,7 @@ def setUpClass(cls):
6262
text.insert('1.0', code_sample)
6363
# Need to pack for creation of code context text widget.
6464
frame.pack(side='left', fill='both', expand=1)
65-
text.pack(side='top', fill='both', expand=1)
65+
text.grid(row=1, column=1, sticky=NSEW)
6666
cls.editor = DummyEditwin(root, frame, text)
6767
codecontext.idleConf.userCfg = testcfg
6868

@@ -77,6 +77,7 @@ def tearDownClass(cls):
7777

7878
def setUp(self):
7979
self.text.yview(0)
80+
self.text['font'] = 'TkFixedFont'
8081
self.cc = codecontext.CodeContext(self.editor)
8182

8283
self.highlight_cfg = {"background": '#abcdef',
@@ -86,10 +87,18 @@ def mock_idleconf_GetHighlight(theme, element):
8687
if element == 'context':
8788
return self.highlight_cfg
8889
return orig_idleConf_GetHighlight(theme, element)
89-
patcher = unittest.mock.patch.object(
90+
GetHighlight_patcher = unittest.mock.patch.object(
9091
codecontext.idleConf, 'GetHighlight', mock_idleconf_GetHighlight)
91-
patcher.start()
92-
self.addCleanup(patcher.stop)
92+
GetHighlight_patcher.start()
93+
self.addCleanup(GetHighlight_patcher.stop)
94+
95+
self.font_override = 'TkFixedFont'
96+
def mock_idleconf_GetFont(root, configType, section):
97+
return self.font_override
98+
GetFont_patcher = unittest.mock.patch.object(
99+
codecontext.idleConf, 'GetFont', mock_idleconf_GetFont)
100+
GetFont_patcher.start()
101+
self.addCleanup(GetFont_patcher.stop)
93102

94103
def tearDown(self):
95104
if self.cc.context:
@@ -339,69 +348,59 @@ def test_timer_event(self, mock_update):
339348
def test_font(self):
340349
eq = self.assertEqual
341350
cc = self.cc
342-
save_font = cc.text['font']
351+
352+
orig_font = cc.text['font']
343353
test_font = 'TkTextFont'
354+
self.assertNotEqual(orig_font, test_font)
344355

345356
# Ensure code context is not active.
346357
if cc.context is not None:
347358
cc.toggle_code_context_event()
348359

360+
self.font_override = test_font
349361
# Nothing breaks or changes with inactive code context.
350-
cc.update_font(test_font)
362+
cc.update_font()
351363

352-
# Activate code context, but no change to font.
353-
cc.toggle_code_context_event()
354-
eq(cc.context['font'], save_font)
355-
# Call font update with the existing font.
356-
cc.update_font(save_font)
357-
eq(cc.context['font'], save_font)
364+
# Activate code context, previous font change is immediately effective.
358365
cc.toggle_code_context_event()
359-
360-
# Change text widget font and activate code context.
361-
cc.text['font'] = test_font
362-
cc.toggle_code_context_event(test_font)
363366
eq(cc.context['font'], test_font)
364367

365-
# Just call the font update.
366-
cc.update_font(save_font)
367-
eq(cc.context['font'], save_font)
368-
cc.text['font'] = save_font
368+
# Call the font update, change is picked up.
369+
self.font_override = orig_font
370+
cc.update_font()
371+
eq(cc.context['font'], orig_font)
369372

370373
def test_highlight_colors(self):
371374
eq = self.assertEqual
372375
cc = self.cc
373-
save_colors = dict(self.highlight_cfg)
376+
377+
orig_colors = dict(self.highlight_cfg)
374378
test_colors = {'background': '#222222', 'foreground': '#ffff00'}
375379

380+
def assert_colors_are_equal(colors):
381+
eq(cc.context['background'], colors['background'])
382+
eq(cc.context['foreground'], colors['foreground'])
383+
376384
# Ensure code context is not active.
377385
if cc.context:
378386
cc.toggle_code_context_event()
379387

388+
self.highlight_cfg = test_colors
380389
# Nothing breaks with inactive code context.
381390
cc.update_highlight_colors()
382391

383-
# Activate code context, but no change to colors.
392+
# Activate code context, previous colors change is immediately effective.
384393
cc.toggle_code_context_event()
385-
eq(cc.context['background'], save_colors['background'])
386-
eq(cc.context['foreground'], save_colors['foreground'])
394+
assert_colors_are_equal(test_colors)
387395

388-
# Call colors update, but no change to font.
396+
# Call colors update with no change to the configured colors.
389397
cc.update_highlight_colors()
390-
eq(cc.context['background'], save_colors['background'])
391-
eq(cc.context['foreground'], save_colors['foreground'])
392-
cc.toggle_code_context_event()
393-
394-
# Change colors and activate code context.
395-
self.highlight_cfg = test_colors
396-
cc.toggle_code_context_event()
397-
eq(cc.context['background'], test_colors['background'])
398-
eq(cc.context['foreground'], test_colors['foreground'])
398+
assert_colors_are_equal(test_colors)
399399

400-
# Change colors and call highlight colors update.
401-
self.highlight_cfg = save_colors
400+
# Call the colors update with code context active, change is picked up.
401+
self.highlight_cfg = orig_colors
402402
cc.update_highlight_colors()
403-
eq(cc.context['background'], save_colors['background'])
404-
eq(cc.context['foreground'], save_colors['foreground'])
403+
assert_colors_are_equal(orig_colors)
405404

406405

407406
class HelperFunctionText(unittest.TestCase):

‎Lib/idlelib/idle_test/test_sidebar.py

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
"""Test sidebar, coverage 93%"""
2+
from itertools import chain
3+
import unittest
4+
import unittest.mock
5+
from test.support import requires
6+
import tkinter as tk
7+
8+
from idlelib.delegator import Delegator
9+
from idlelib.percolator import Percolator
10+
import idlelib.sidebar
11+
12+
13+
class Dummy_editwin:
14+
def __init__(self, text):
15+
self.text = text
16+
self.text_frame = self.text.master
17+
self.per = Percolator(text)
18+
self.undo = Delegator()
19+
self.per.insertfilter(self.undo)
20+
21+
def setvar(self, name, value):
22+
pass
23+
24+
def getlineno(self, index):
25+
return int(float(self.text.index(index)))
26+
27+
28+
class LineNumbersTest(unittest.TestCase):
29+
30+
@classmethod
31+
def setUpClass(cls):
32+
requires('gui')
33+
cls.root = tk.Tk()
34+
35+
cls.text_frame = tk.Frame(cls.root)
36+
cls.text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
37+
cls.text_frame.rowconfigure(1, weight=1)
38+
cls.text_frame.columnconfigure(1, weight=1)
39+
40+
cls.text = tk.Text(cls.text_frame, width=80, height=24, wrap=tk.NONE)
41+
cls.text.grid(row=1, column=1, sticky=tk.NSEW)
42+
43+
cls.editwin = Dummy_editwin(cls.text)
44+
cls.editwin.vbar = tk.Scrollbar(cls.text_frame)
45+
46+
@classmethod
47+
def tearDownClass(cls):
48+
cls.editwin.per.close()
49+
cls.root.update()
50+
cls.root.destroy()
51+
del cls.text, cls.text_frame, cls.editwin, cls.root
52+
53+
def setUp(self):
54+
self.linenumber = idlelib.sidebar.LineNumbers(self.editwin)
55+
56+
self.highlight_cfg = {"background": '#abcdef',
57+
"foreground": '#123456'}
58+
orig_idleConf_GetHighlight = idlelib.sidebar.idleConf.GetHighlight
59+
def mock_idleconf_GetHighlight(theme, element):
60+
if element == 'linenumber':
61+
return self.highlight_cfg
62+
return orig_idleConf_GetHighlight(theme, element)
63+
GetHighlight_patcher = unittest.mock.patch.object(
64+
idlelib.sidebar.idleConf, 'GetHighlight', mock_idleconf_GetHighlight)
65+
GetHighlight_patcher.start()
66+
self.addCleanup(GetHighlight_patcher.stop)
67+
68+
self.font_override = 'TkFixedFont'
69+
def mock_idleconf_GetFont(root, configType, section):
70+
return self.font_override
71+
GetFont_patcher = unittest.mock.patch.object(
72+
idlelib.sidebar.idleConf, 'GetFont', mock_idleconf_GetFont)
73+
GetFont_patcher.start()
74+
self.addCleanup(GetFont_patcher.stop)
75+
76+
def tearDown(self):
77+
self.text.delete('1.0', 'end')
78+
79+
def get_selection(self):
80+
return tuple(map(str, self.text.tag_ranges('sel')))
81+
82+
def get_line_screen_position(self, line):
83+
bbox = self.linenumber.sidebar_text.bbox(f'{line}.end -1c')
84+
x = bbox[0] + 2
85+
y = bbox[1] + 2
86+
return x, y
87+
88+
def assert_state_disabled(self):
89+
state = self.linenumber.sidebar_text.config()['state']
90+
self.assertEqual(state[-1], tk.DISABLED)
91+
92+
def get_sidebar_text_contents(self):
93+
return self.linenumber.sidebar_text.get('1.0', tk.END)
94+
95+
def assert_sidebar_n_lines(self, n_lines):
96+
expected = '\n'.join(chain(map(str, range(1, n_lines + 1)), ['']))
97+
self.assertEqual(self.get_sidebar_text_contents(), expected)
98+
99+
def assert_text_equals(self, expected):
100+
return self.assertEqual(self.text.get('1.0', 'end'), expected)
101+
102+
def test_init_empty(self):
103+
self.assert_sidebar_n_lines(1)
104+
105+
def test_init_not_empty(self):
106+
self.text.insert('insert', 'foo bar\n'*3)
107+
self.assert_text_equals('foo bar\n'*3 + '\n')
108+
self.assert_sidebar_n_lines(4)
109+
110+
def test_toggle_linenumbering(self):
111+
self.assertEqual(self.linenumber.is_shown, False)
112+
self.linenumber.show_sidebar()
113+
self.assertEqual(self.linenumber.is_shown, True)
114+
self.linenumber.hide_sidebar()
115+
self.assertEqual(self.linenumber.is_shown, False)
116+
self.linenumber.hide_sidebar()
117+
self.assertEqual(self.linenumber.is_shown, False)
118+
self.linenumber.show_sidebar()
119+
self.assertEqual(self.linenumber.is_shown, True)
120+
self.linenumber.show_sidebar()
121+
self.assertEqual(self.linenumber.is_shown, True)
122+
123+
def test_insert(self):
124+
self.text.insert('insert', 'foobar')
125+
self.assert_text_equals('foobar\n')
126+
self.assert_sidebar_n_lines(1)
127+
self.assert_state_disabled()
128+
129+
self.text.insert('insert', '\nfoo')
130+
self.assert_text_equals('foobar\nfoo\n')
131+
self.assert_sidebar_n_lines(2)
132+
self.assert_state_disabled()
133+
134+
self.text.insert('insert', 'hello\n'*2)
135+
self.assert_text_equals('foobar\nfoohello\nhello\n\n')
136+
self.assert_sidebar_n_lines(4)
137+
self.assert_state_disabled()
138+
139+
self.text.insert('insert', '\nworld')
140+
self.assert_text_equals('foobar\nfoohello\nhello\n\nworld\n')
141+
self.assert_sidebar_n_lines(5)
142+
self.assert_state_disabled()
143+
144+
def test_delete(self):
145+
self.text.insert('insert', 'foobar')
146+
self.assert_text_equals('foobar\n')
147+
self.text.delete('1.1', '1.3')
148+
self.assert_text_equals('fbar\n')
149+
self.assert_sidebar_n_lines(1)
150+
self.assert_state_disabled()
151+
152+
self.text.insert('insert', 'foo\n'*2)
153+
self.assert_text_equals('fbarfoo\nfoo\n\n')
154+
self.assert_sidebar_n_lines(3)
155+
self.assert_state_disabled()
156+
157+
# Note: deleting up to "2.end" doesn't delete the final newline.
158+
self.text.delete('2.0', '2.end')
159+
self.assert_text_equals('fbarfoo\n\n\n')
160+
self.assert_sidebar_n_lines(3)
161+
self.assert_state_disabled()
162+
163+
self.text.delete('1.3', 'end')
164+
self.assert_text_equals('fba\n')
165+
self.assert_sidebar_n_lines(1)
166+
self.assert_state_disabled()
167+
168+
# Note: Text widgets always keep a single '\n' character at the end.
169+
self.text.delete('1.0', 'end')
170+
self.assert_text_equals('\n')
171+
self.assert_sidebar_n_lines(1)
172+
self.assert_state_disabled()
173+
174+
def test_sidebar_text_width(self):
175+
"""
176+
Test that linenumber text widget is always at the minimum
177+
width
178+
"""
179+
def get_width():
180+
return self.linenumber.sidebar_text.config()['width'][-1]
181+
182+
self.assert_sidebar_n_lines(1)
183+
self.assertEqual(get_width(), 1)
184+
185+
self.text.insert('insert', 'foo')
186+
self.assert_sidebar_n_lines(1)
187+
self.assertEqual(get_width(), 1)
188+
189+
self.text.insert('insert', 'foo\n'*8)
190+
self.assert_sidebar_n_lines(9)
191+
self.assertEqual(get_width(), 1)
192+
193+
self.text.insert('insert', 'foo\n')
194+
self.assert_sidebar_n_lines(10)
195+
self.assertEqual(get_width(), 2)
196+
197+
self.text.insert('insert', 'foo\n')
198+
self.assert_sidebar_n_lines(11)
199+
self.assertEqual(get_width(), 2)
200+
201+
self.text.delete('insert -1l linestart', 'insert linestart')
202+
self.assert_sidebar_n_lines(10)
203+
self.assertEqual(get_width(), 2)
204+
205+
self.text.delete('insert -1l linestart', 'insert linestart')
206+
self.assert_sidebar_n_lines(9)
207+
self.assertEqual(get_width(), 1)
208+
209+
self.text.insert('insert', 'foo\n'*90)
210+
self.assert_sidebar_n_lines(99)
211+
self.assertEqual(get_width(), 2)
212+
213+
self.text.insert('insert', 'foo\n')
214+
self.assert_sidebar_n_lines(100)
215+
self.assertEqual(get_width(), 3)
216+
217+
self.text.insert('insert', 'foo\n')
218+
self.assert_sidebar_n_lines(101)
219+
self.assertEqual(get_width(), 3)
220+
221+
self.text.delete('insert -1l linestart', 'insert linestart')
222+
self.assert_sidebar_n_lines(100)
223+
self.assertEqual(get_width(), 3)
224+
225+
self.text.delete('insert -1l linestart', 'insert linestart')
226+
self.assert_sidebar_n_lines(99)
227+
self.assertEqual(get_width(), 2)
228+
229+
self.text.delete('50.0 -1c', 'end -1c')
230+
self.assert_sidebar_n_lines(49)
231+
self.assertEqual(get_width(), 2)
232+
233+
self.text.delete('5.0 -1c', 'end -1c')
234+
self.assert_sidebar_n_lines(4)
235+
self.assertEqual(get_width(), 1)
236+
237+
# Note: Text widgets always keep a single '\n' character at the end.
238+
self.text.delete('1.0', 'end -1c')
239+
self.assert_sidebar_n_lines(1)
240+
self.assertEqual(get_width(), 1)
241+
242+
def test_click_selection(self):
243+
self.linenumber.show_sidebar()
244+
self.text.insert('1.0', 'one\ntwo\nthree\nfour\n')
245+
self.root.update()
246+
247+
# Click on the second line.
248+
x, y = self.get_line_screen_position(2)
249+
self.linenumber.sidebar_text.event_generate('<Button-1>', x=x, y=y)
250+
self.linenumber.sidebar_text.update()
251+
self.root.update()
252+
253+
self.assertEqual(self.get_selection(), ('2.0', '3.0'))
254+
255+
def test_drag_selection(self):
256+
self.linenumber.show_sidebar()
257+
self.text.insert('1.0', 'one\ntwo\nthree\nfour\n')
258+
self.root.update()
259+
260+
# Drag from the first line to the third line.
261+
start_x, start_y = self.get_line_screen_position(1)
262+
end_x, end_y = self.get_line_screen_position(3)
263+
self.linenumber.sidebar_text.event_generate('<Button-1>',
264+
x=start_x, y=start_y)
265+
self.linenumber.sidebar_text.event_generate('<B1-Motion>',
266+
x=start_x, y=start_y)
267+
self.linenumber.sidebar_text.event_generate('<B1-Motion>',
268+
x=end_x, y=end_y)
269+
self.linenumber.sidebar_text.event_generate('<ButtonRelease-1>',
270+
x=end_x, y=end_y)
271+
self.root.update()
272+
273+
self.assertEqual(self.get_selection(), ('1.0', '4.0'))
274+
275+
def test_scroll(self):
276+
self.linenumber.show_sidebar()
277+
self.text.insert('1.0', 'line\n' * 100)
278+
self.root.update()
279+
280+
# Scroll down 10 lines.
281+
self.text.yview_scroll(10, 'unit')
282+
self.root.update()
283+
self.assertEqual(self.text.index('@0,0'), '11.0')
284+
self.assertEqual(self.linenumber.sidebar_text.index('@0,0'), '11.0')
285+
286+
# Generate a mouse-wheel event and make sure it scrolled up or down.
287+
# The meaning of the "delta" is OS-dependant, so this just checks for
288+
# any change.
289+
self.linenumber.sidebar_text.event_generate('<MouseWheel>',
290+
x=0, y=0,
291+
delta=10)
292+
self.root.update()
293+
self.assertNotEqual(self.text.index('@0,0'), '11.0')
294+
self.assertNotEqual(self.linenumber.sidebar_text.index('@0,0'), '11.0')
295+
296+
def test_font(self):
297+
ln = self.linenumber
298+
299+
orig_font = ln.sidebar_text['font']
300+
test_font = 'TkTextFont'
301+
self.assertNotEqual(orig_font, test_font)
302+
303+
# Ensure line numbers aren't shown.
304+
ln.hide_sidebar()
305+
306+
self.font_override = test_font
307+
# Nothing breaks when line numbers aren't shown.
308+
ln.update_font()
309+
310+
# Activate line numbers, previous font change is immediately effective.
311+
ln.show_sidebar()
312+
self.assertEqual(ln.sidebar_text['font'], test_font)
313+
314+
# Call the font update with line numbers shown, change is picked up.
315+
self.font_override = orig_font
316+
ln.update_font()
317+
self.assertEqual(ln.sidebar_text['font'], orig_font)
318+
319+
def test_highlight_colors(self):
320+
ln = self.linenumber
321+
322+
orig_colors = dict(self.highlight_cfg)
323+
test_colors = {'background': '#222222', 'foreground': '#ffff00'}
324+
325+
def assert_colors_are_equal(colors):
326+
self.assertEqual(ln.sidebar_text['background'], colors['background'])
327+
self.assertEqual(ln.sidebar_text['foreground'], colors['foreground'])
328+
329+
# Ensure line numbers aren't shown.
330+
ln.hide_sidebar()
331+
332+
self.highlight_cfg = test_colors
333+
# Nothing breaks with inactive code context.
334+
ln.update_colors()
335+
336+
# Show line numbers, previous colors change is immediately effective.
337+
ln.show_sidebar()
338+
assert_colors_are_equal(test_colors)
339+
340+
# Call colors update with no change to the configured colors.
341+
ln.update_colors()
342+
assert_colors_are_equal(test_colors)
343+
344+
# Call the colors update with line numbers shown, change is picked up.
345+
self.highlight_cfg = orig_colors
346+
ln.update_colors()
347+
assert_colors_are_equal(orig_colors)
348+
349+
350+
if __name__ == '__main__':
351+
unittest.main(verbosity=2)

‎Lib/idlelib/mainmenu.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@
100100
('Configure _IDLE', '<<open-config-dialog>>'),
101101
None,
102102
('Show _Code Context', '<<toggle-code-context>>'),
103-
('Zoom Height', '<<zoom-height>>'),
103+
('Show _Line Numbers', '<<toggle-line-numbers>>'),
104+
('_Zoom Height', '<<zoom-height>>'),
104105
]),
105106

106107
('window', [

‎Lib/idlelib/outwin.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,11 @@ class OutputWindow(EditorWindow):
7474
("Go to file/line", "<<goto-file-line>>", None),
7575
]
7676

77-
allow_codecontext = False
77+
allow_code_context = False
7878

7979
def __init__(self, *args):
8080
EditorWindow.__init__(self, *args)
8181
self.text.bind("<<goto-file-line>>", self.goto_file_line)
82-
self.text.unbind("<<toggle-code-context>>")
83-
self.update_menu_state('options', '*Code Context', 'disabled')
8482

8583
# Customize EditorWindow
8684
def ispythonsource(self, filename):

‎Lib/idlelib/pyshell.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,8 @@ class PyShell(OutputWindow):
861861
("Squeeze", "<<squeeze-current-text>>"),
862862
]
863863

864+
allow_line_numbers = False
865+
864866
# New classes
865867
from idlelib.history import History
866868

‎Lib/idlelib/sidebar.py

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
"""Line numbering implementation for IDLE as an extension.
2+
Includes BaseSideBar which can be extended for other sidebar based extensions
3+
"""
4+
import functools
5+
import itertools
6+
7+
import tkinter as tk
8+
from idlelib.config import idleConf
9+
from idlelib.delegator import Delegator
10+
11+
12+
def get_end_linenumber(text):
13+
"""Utility to get the last line's number in a Tk text widget."""
14+
return int(float(text.index('end-1c')))
15+
16+
17+
def get_widget_padding(widget):
18+
"""Get the total padding of a Tk widget, including its border."""
19+
# TODO: use also in codecontext.py
20+
manager = widget.winfo_manager()
21+
if manager == 'pack':
22+
info = widget.pack_info()
23+
elif manager == 'grid':
24+
info = widget.grid_info()
25+
else:
26+
raise ValueError(f"Unsupported geometry manager: {manager}")
27+
28+
# All values are passed through getint(), since some
29+
# values may be pixel objects, which can't simply be added to ints.
30+
padx = sum(map(widget.tk.getint, [
31+
info['padx'],
32+
widget.cget('padx'),
33+
widget.cget('border'),
34+
]))
35+
pady = sum(map(widget.tk.getint, [
36+
info['pady'],
37+
widget.cget('pady'),
38+
widget.cget('border'),
39+
]))
40+
return padx, pady
41+
42+
43+
class BaseSideBar:
44+
"""
45+
The base class for extensions which require a sidebar.
46+
"""
47+
def __init__(self, editwin):
48+
self.editwin = editwin
49+
self.parent = editwin.text_frame
50+
self.text = editwin.text
51+
52+
_padx, pady = get_widget_padding(self.text)
53+
self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE,
54+
padx=0, pady=pady,
55+
borderwidth=0, highlightthickness=0)
56+
self.sidebar_text.config(state=tk.DISABLED)
57+
self.text['yscrollcommand'] = self.redirect_yscroll_event
58+
self.update_font()
59+
self.update_colors()
60+
61+
self.is_shown = False
62+
63+
def update_font(self):
64+
"""Update the sidebar text font, usually after config changes."""
65+
font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
66+
self._update_font(font)
67+
68+
def _update_font(self, font):
69+
self.sidebar_text['font'] = font
70+
71+
def update_colors(self):
72+
"""Update the sidebar text colors, usually after config changes."""
73+
colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'normal')
74+
self._update_colors(foreground=colors['foreground'],
75+
background=colors['background'])
76+
77+
def _update_colors(self, foreground, background):
78+
self.sidebar_text.config(
79+
fg=foreground, bg=background,
80+
selectforeground=foreground, selectbackground=background,
81+
inactiveselectbackground=background,
82+
)
83+
84+
def show_sidebar(self):
85+
if not self.is_shown:
86+
self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW)
87+
self.is_shown = True
88+
89+
def hide_sidebar(self):
90+
if self.is_shown:
91+
self.sidebar_text.grid_forget()
92+
self.is_shown = False
93+
94+
def redirect_yscroll_event(self, *args, **kwargs):
95+
"""Redirect vertical scrolling to the main editor text widget.
96+
97+
The scroll bar is also updated.
98+
"""
99+
self.editwin.vbar.set(*args)
100+
self.sidebar_text.yview_moveto(args[0])
101+
return 'break'
102+
103+
def redirect_focusin_event(self, event):
104+
"""Redirect focus-in events to the main editor text widget."""
105+
self.text.focus_set()
106+
return 'break'
107+
108+
def redirect_mousebutton_event(self, event, event_name):
109+
"""Redirect mouse button events to the main editor text widget."""
110+
self.text.focus_set()
111+
self.text.event_generate(event_name, x=0, y=event.y)
112+
return 'break'
113+
114+
def redirect_mousewheel_event(self, event):
115+
"""Redirect mouse wheel events to the editwin text widget."""
116+
self.text.event_generate('<MouseWheel>',
117+
x=0, y=event.y, delta=event.delta)
118+
return 'break'
119+
120+
121+
class EndLineDelegator(Delegator):
122+
"""Generate callbacks with the current end line number after
123+
insert or delete operations"""
124+
def __init__(self, changed_callback):
125+
"""
126+
changed_callback - Callable, will be called after insert
127+
or delete operations with the current
128+
end line number.
129+
"""
130+
Delegator.__init__(self)
131+
self.changed_callback = changed_callback
132+
133+
def insert(self, index, chars, tags=None):
134+
self.delegate.insert(index, chars, tags)
135+
self.changed_callback(get_end_linenumber(self.delegate))
136+
137+
def delete(self, index1, index2=None):
138+
self.delegate.delete(index1, index2)
139+
self.changed_callback(get_end_linenumber(self.delegate))
140+
141+
142+
class LineNumbers(BaseSideBar):
143+
"""Line numbers support for editor windows."""
144+
def __init__(self, editwin):
145+
BaseSideBar.__init__(self, editwin)
146+
self.prev_end = 1
147+
self._sidebar_width_type = type(self.sidebar_text['width'])
148+
self.sidebar_text.config(state=tk.NORMAL)
149+
self.sidebar_text.insert('insert', '1', 'linenumber')
150+
self.sidebar_text.config(state=tk.DISABLED)
151+
self.sidebar_text.config(takefocus=False, exportselection=False)
152+
self.sidebar_text.tag_config('linenumber', justify=tk.RIGHT)
153+
154+
self.bind_events()
155+
156+
end = get_end_linenumber(self.text)
157+
self.update_sidebar_text(end)
158+
159+
end_line_delegator = EndLineDelegator(self.update_sidebar_text)
160+
# Insert the delegator after the undo delegator, so that line numbers
161+
# are properly updated after undo and redo actions.
162+
end_line_delegator.setdelegate(self.editwin.undo.delegate)
163+
self.editwin.undo.setdelegate(end_line_delegator)
164+
# Reset the delegator caches of the delegators "above" the
165+
# end line delegator we just inserted.
166+
delegator = self.editwin.per.top
167+
while delegator is not end_line_delegator:
168+
delegator.resetcache()
169+
delegator = delegator.delegate
170+
171+
self.is_shown = False
172+
173+
def bind_events(self):
174+
# Ensure focus is always redirected to the main editor text widget.
175+
self.sidebar_text.bind('<FocusIn>', self.redirect_focusin_event)
176+
177+
# Redirect mouse scrolling to the main editor text widget.
178+
#
179+
# Note that without this, scrolling with the mouse only scrolls
180+
# the line numbers.
181+
self.sidebar_text.bind('<MouseWheel>', self.redirect_mousewheel_event)
182+
183+
# Redirect mouse button events to the main editor text widget,
184+
# except for the left mouse button (1).
185+
#
186+
# Note: X-11 sends Button-4 and Button-5 events for the scroll wheel.
187+
def bind_mouse_event(event_name, target_event_name):
188+
handler = functools.partial(self.redirect_mousebutton_event,
189+
event_name=target_event_name)
190+
self.sidebar_text.bind(event_name, handler)
191+
192+
for button in [2, 3, 4, 5]:
193+
for event_name in (f'<Button-{button}>',
194+
f'<ButtonRelease-{button}>',
195+
f'<B{button}-Motion>',
196+
):
197+
bind_mouse_event(event_name, target_event_name=event_name)
198+
199+
# Convert double- and triple-click events to normal click events,
200+
# since event_generate() doesn't allow generating such events.
201+
for event_name in (f'<Double-Button-{button}>',
202+
f'<Triple-Button-{button}>',
203+
):
204+
bind_mouse_event(event_name,
205+
target_event_name=f'<Button-{button}>')
206+
207+
start_line = None
208+
def b1_mousedown_handler(event):
209+
# select the entire line
210+
lineno = self.editwin.getlineno(f"@0,{event.y}")
211+
self.text.tag_remove("sel", "1.0", "end")
212+
self.text.tag_add("sel", f"{lineno}.0", f"{lineno+1}.0")
213+
self.text.mark_set("insert", f"{lineno+1}.0")
214+
215+
# remember this line in case this is the beginning of dragging
216+
nonlocal start_line
217+
start_line = lineno
218+
self.sidebar_text.bind('<Button-1>', b1_mousedown_handler)
219+
220+
# These are set by b1_motion_handler() and read by selection_handler();
221+
# see below. last_y is passed this way since the mouse Y-coordinate
222+
# is not available on selection event objects. last_yview is passed
223+
# this way to recognize scrolling while the mouse isn't moving.
224+
last_y = last_yview = None
225+
226+
def drag_update_selection_and_insert_mark(y_coord):
227+
"""Helper function for drag and selection event handlers."""
228+
lineno = self.editwin.getlineno(f"@0,{y_coord}")
229+
a, b = sorted([start_line, lineno])
230+
self.text.tag_remove("sel", "1.0", "end")
231+
self.text.tag_add("sel", f"{a}.0", f"{b+1}.0")
232+
self.text.mark_set("insert",
233+
f"{lineno if lineno == a else lineno + 1}.0")
234+
235+
# Special handling of dragging with mouse button 1. In "normal" text
236+
# widgets this selects text, but the line numbers text widget has
237+
# selection disabled. Still, dragging triggers some selection-related
238+
# functionality under the hood. Specifically, dragging to above or
239+
# below the text widget triggers scrolling, in a way that bypasses the
240+
# other scrolling synchronization mechanisms.i
241+
def b1_drag_handler(event, *args):
242+
nonlocal last_y
243+
nonlocal last_yview
244+
last_y = event.y
245+
last_yview = self.sidebar_text.yview()
246+
if not 0 <= last_y <= self.sidebar_text.winfo_height():
247+
self.text.yview_moveto(last_yview[0])
248+
drag_update_selection_and_insert_mark(event.y)
249+
self.sidebar_text.bind('<B1-Motion>', b1_drag_handler)
250+
251+
# With mouse-drag scrolling fixed by the above, there is still an edge-
252+
# case we need to handle: When drag-scrolling, scrolling can continue
253+
# while the mouse isn't moving, leading to the above fix not scrolling
254+
# properly.
255+
def selection_handler(event):
256+
yview = self.sidebar_text.yview()
257+
if yview != last_yview:
258+
self.text.yview_moveto(yview[0])
259+
drag_update_selection_and_insert_mark(last_y)
260+
self.sidebar_text.bind('<<Selection>>', selection_handler)
261+
262+
def update_colors(self):
263+
"""Update the sidebar text colors, usually after config changes."""
264+
colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
265+
self._update_colors(foreground=colors['foreground'],
266+
background=colors['background'])
267+
268+
def update_sidebar_text(self, end):
269+
"""
270+
Perform the following action:
271+
Each line sidebar_text contains the linenumber for that line
272+
Synchronize with editwin.text so that both sidebar_text and
273+
editwin.text contain the same number of lines"""
274+
if end == self.prev_end:
275+
return
276+
277+
width_difference = len(str(end)) - len(str(self.prev_end))
278+
if width_difference:
279+
cur_width = int(float(self.sidebar_text['width']))
280+
new_width = cur_width + width_difference
281+
self.sidebar_text['width'] = self._sidebar_width_type(new_width)
282+
283+
self.sidebar_text.config(state=tk.NORMAL)
284+
if end > self.prev_end:
285+
new_text = '\n'.join(itertools.chain(
286+
[''],
287+
map(str, range(self.prev_end + 1, end + 1)),
288+
))
289+
self.sidebar_text.insert(f'end -1c', new_text, 'linenumber')
290+
else:
291+
self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c')
292+
self.sidebar_text.config(state=tk.DISABLED)
293+
294+
self.prev_end = end
295+
296+
297+
def _linenumbers_drag_scrolling(parent): # htest #
298+
from idlelib.idle_test.test_sidebar import Dummy_editwin
299+
300+
toplevel = tk.Toplevel(parent)
301+
text_frame = tk.Frame(toplevel)
302+
text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
303+
text_frame.rowconfigure(1, weight=1)
304+
text_frame.columnconfigure(1, weight=1)
305+
306+
font = idleConf.GetFont(toplevel, 'main', 'EditorWindow')
307+
text = tk.Text(text_frame, width=80, height=24, wrap=tk.NONE, font=font)
308+
text.grid(row=1, column=1, sticky=tk.NSEW)
309+
310+
editwin = Dummy_editwin(text)
311+
editwin.vbar = tk.Scrollbar(text_frame)
312+
313+
linenumbers = LineNumbers(editwin)
314+
linenumbers.show_sidebar()
315+
316+
text.insert('1.0', '\n'.join('a'*i for i in range(1, 101)))
317+
318+
319+
if __name__ == '__main__':
320+
from unittest import main
321+
main('idlelib.idle_test.test_sidebar', verbosity=2, exit=False)
322+
323+
from idlelib.idle_test.htest import run
324+
run(_linenumbers_drag_scrolling)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Add optional line numbers for IDLE editor windows. Windows
2+
open without line numbers unless set otherwise in the General
3+
tab of the configuration dialog.
4+

0 commit comments

Comments
 (0)
Please sign in to comment.