jpayne@68
|
1 """codecontext - display the block context above the edit window
|
jpayne@68
|
2
|
jpayne@68
|
3 Once code has scrolled off the top of a window, it can be difficult to
|
jpayne@68
|
4 determine which block you are in. This extension implements a pane at the top
|
jpayne@68
|
5 of each IDLE edit window which provides block structure hints. These hints are
|
jpayne@68
|
6 the lines which contain the block opening keywords, e.g. 'if', for the
|
jpayne@68
|
7 enclosing block. The number of hint lines is determined by the maxlines
|
jpayne@68
|
8 variable in the codecontext section of config-extensions.def. Lines which do
|
jpayne@68
|
9 not open blocks are not shown in the context hints pane.
|
jpayne@68
|
10
|
jpayne@68
|
11 """
|
jpayne@68
|
12 import re
|
jpayne@68
|
13 from sys import maxsize as INFINITY
|
jpayne@68
|
14
|
jpayne@68
|
15 import tkinter
|
jpayne@68
|
16 from tkinter.constants import NSEW, SUNKEN
|
jpayne@68
|
17
|
jpayne@68
|
18 from idlelib.config import idleConf
|
jpayne@68
|
19
|
jpayne@68
|
20 BLOCKOPENERS = {"class", "def", "elif", "else", "except", "finally", "for",
|
jpayne@68
|
21 "if", "try", "while", "with", "async"}
|
jpayne@68
|
22
|
jpayne@68
|
23
|
jpayne@68
|
24 def get_spaces_firstword(codeline, c=re.compile(r"^(\s*)(\w*)")):
|
jpayne@68
|
25 "Extract the beginning whitespace and first word from codeline."
|
jpayne@68
|
26 return c.match(codeline).groups()
|
jpayne@68
|
27
|
jpayne@68
|
28
|
jpayne@68
|
29 def get_line_info(codeline):
|
jpayne@68
|
30 """Return tuple of (line indent value, codeline, block start keyword).
|
jpayne@68
|
31
|
jpayne@68
|
32 The indentation of empty lines (or comment lines) is INFINITY.
|
jpayne@68
|
33 If the line does not start a block, the keyword value is False.
|
jpayne@68
|
34 """
|
jpayne@68
|
35 spaces, firstword = get_spaces_firstword(codeline)
|
jpayne@68
|
36 indent = len(spaces)
|
jpayne@68
|
37 if len(codeline) == indent or codeline[indent] == '#':
|
jpayne@68
|
38 indent = INFINITY
|
jpayne@68
|
39 opener = firstword in BLOCKOPENERS and firstword
|
jpayne@68
|
40 return indent, codeline, opener
|
jpayne@68
|
41
|
jpayne@68
|
42
|
jpayne@68
|
43 class CodeContext:
|
jpayne@68
|
44 "Display block context above the edit window."
|
jpayne@68
|
45 UPDATEINTERVAL = 100 # millisec
|
jpayne@68
|
46
|
jpayne@68
|
47 def __init__(self, editwin):
|
jpayne@68
|
48 """Initialize settings for context block.
|
jpayne@68
|
49
|
jpayne@68
|
50 editwin is the Editor window for the context block.
|
jpayne@68
|
51 self.text is the editor window text widget.
|
jpayne@68
|
52
|
jpayne@68
|
53 self.context displays the code context text above the editor text.
|
jpayne@68
|
54 Initially None, it is toggled via <<toggle-code-context>>.
|
jpayne@68
|
55 self.topvisible is the number of the top text line displayed.
|
jpayne@68
|
56 self.info is a list of (line number, indent level, line text,
|
jpayne@68
|
57 block keyword) tuples for the block structure above topvisible.
|
jpayne@68
|
58 self.info[0] is initialized with a 'dummy' line which
|
jpayne@68
|
59 starts the toplevel 'block' of the module.
|
jpayne@68
|
60
|
jpayne@68
|
61 self.t1 and self.t2 are two timer events on the editor text widget to
|
jpayne@68
|
62 monitor for changes to the context text or editor font.
|
jpayne@68
|
63 """
|
jpayne@68
|
64 self.editwin = editwin
|
jpayne@68
|
65 self.text = editwin.text
|
jpayne@68
|
66 self._reset()
|
jpayne@68
|
67
|
jpayne@68
|
68 def _reset(self):
|
jpayne@68
|
69 self.context = None
|
jpayne@68
|
70 self.cell00 = None
|
jpayne@68
|
71 self.t1 = None
|
jpayne@68
|
72 self.topvisible = 1
|
jpayne@68
|
73 self.info = [(0, -1, "", False)]
|
jpayne@68
|
74
|
jpayne@68
|
75 @classmethod
|
jpayne@68
|
76 def reload(cls):
|
jpayne@68
|
77 "Load class variables from config."
|
jpayne@68
|
78 cls.context_depth = idleConf.GetOption("extensions", "CodeContext",
|
jpayne@68
|
79 "maxlines", type="int",
|
jpayne@68
|
80 default=15)
|
jpayne@68
|
81
|
jpayne@68
|
82 def __del__(self):
|
jpayne@68
|
83 "Cancel scheduled events."
|
jpayne@68
|
84 if self.t1 is not None:
|
jpayne@68
|
85 try:
|
jpayne@68
|
86 self.text.after_cancel(self.t1)
|
jpayne@68
|
87 except tkinter.TclError:
|
jpayne@68
|
88 pass
|
jpayne@68
|
89 self.t1 = None
|
jpayne@68
|
90
|
jpayne@68
|
91 def toggle_code_context_event(self, event=None):
|
jpayne@68
|
92 """Toggle code context display.
|
jpayne@68
|
93
|
jpayne@68
|
94 If self.context doesn't exist, create it to match the size of the editor
|
jpayne@68
|
95 window text (toggle on). If it does exist, destroy it (toggle off).
|
jpayne@68
|
96 Return 'break' to complete the processing of the binding.
|
jpayne@68
|
97 """
|
jpayne@68
|
98 if self.context is None:
|
jpayne@68
|
99 # Calculate the border width and horizontal padding required to
|
jpayne@68
|
100 # align the context with the text in the main Text widget.
|
jpayne@68
|
101 #
|
jpayne@68
|
102 # All values are passed through getint(), since some
|
jpayne@68
|
103 # values may be pixel objects, which can't simply be added to ints.
|
jpayne@68
|
104 widgets = self.editwin.text, self.editwin.text_frame
|
jpayne@68
|
105 # Calculate the required horizontal padding and border width.
|
jpayne@68
|
106 padx = 0
|
jpayne@68
|
107 border = 0
|
jpayne@68
|
108 for widget in widgets:
|
jpayne@68
|
109 info = (widget.grid_info()
|
jpayne@68
|
110 if widget is self.editwin.text
|
jpayne@68
|
111 else widget.pack_info())
|
jpayne@68
|
112 padx += widget.tk.getint(info['padx'])
|
jpayne@68
|
113 padx += widget.tk.getint(widget.cget('padx'))
|
jpayne@68
|
114 border += widget.tk.getint(widget.cget('border'))
|
jpayne@68
|
115 self.context = tkinter.Text(
|
jpayne@68
|
116 self.editwin.text_frame,
|
jpayne@68
|
117 height=1,
|
jpayne@68
|
118 width=1, # Don't request more than we get.
|
jpayne@68
|
119 highlightthickness=0,
|
jpayne@68
|
120 padx=padx, border=border, relief=SUNKEN, state='disabled')
|
jpayne@68
|
121 self.update_font()
|
jpayne@68
|
122 self.update_highlight_colors()
|
jpayne@68
|
123 self.context.bind('<ButtonRelease-1>', self.jumptoline)
|
jpayne@68
|
124 # Get the current context and initiate the recurring update event.
|
jpayne@68
|
125 self.timer_event()
|
jpayne@68
|
126 # Grid the context widget above the text widget.
|
jpayne@68
|
127 self.context.grid(row=0, column=1, sticky=NSEW)
|
jpayne@68
|
128
|
jpayne@68
|
129 line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(),
|
jpayne@68
|
130 'linenumber')
|
jpayne@68
|
131 self.cell00 = tkinter.Frame(self.editwin.text_frame,
|
jpayne@68
|
132 bg=line_number_colors['background'])
|
jpayne@68
|
133 self.cell00.grid(row=0, column=0, sticky=NSEW)
|
jpayne@68
|
134 menu_status = 'Hide'
|
jpayne@68
|
135 else:
|
jpayne@68
|
136 self.context.destroy()
|
jpayne@68
|
137 self.context = None
|
jpayne@68
|
138 self.cell00.destroy()
|
jpayne@68
|
139 self.cell00 = None
|
jpayne@68
|
140 self.text.after_cancel(self.t1)
|
jpayne@68
|
141 self._reset()
|
jpayne@68
|
142 menu_status = 'Show'
|
jpayne@68
|
143 self.editwin.update_menu_label(menu='options', index='* Code Context',
|
jpayne@68
|
144 label=f'{menu_status} Code Context')
|
jpayne@68
|
145 return "break"
|
jpayne@68
|
146
|
jpayne@68
|
147 def get_context(self, new_topvisible, stopline=1, stopindent=0):
|
jpayne@68
|
148 """Return a list of block line tuples and the 'last' indent.
|
jpayne@68
|
149
|
jpayne@68
|
150 The tuple fields are (linenum, indent, text, opener).
|
jpayne@68
|
151 The list represents header lines from new_topvisible back to
|
jpayne@68
|
152 stopline with successively shorter indents > stopindent.
|
jpayne@68
|
153 The list is returned ordered by line number.
|
jpayne@68
|
154 Last indent returned is the smallest indent observed.
|
jpayne@68
|
155 """
|
jpayne@68
|
156 assert stopline > 0
|
jpayne@68
|
157 lines = []
|
jpayne@68
|
158 # The indentation level we are currently in.
|
jpayne@68
|
159 lastindent = INFINITY
|
jpayne@68
|
160 # For a line to be interesting, it must begin with a block opening
|
jpayne@68
|
161 # keyword, and have less indentation than lastindent.
|
jpayne@68
|
162 for linenum in range(new_topvisible, stopline-1, -1):
|
jpayne@68
|
163 codeline = self.text.get(f'{linenum}.0', f'{linenum}.end')
|
jpayne@68
|
164 indent, text, opener = get_line_info(codeline)
|
jpayne@68
|
165 if indent < lastindent:
|
jpayne@68
|
166 lastindent = indent
|
jpayne@68
|
167 if opener in ("else", "elif"):
|
jpayne@68
|
168 # Also show the if statement.
|
jpayne@68
|
169 lastindent += 1
|
jpayne@68
|
170 if opener and linenum < new_topvisible and indent >= stopindent:
|
jpayne@68
|
171 lines.append((linenum, indent, text, opener))
|
jpayne@68
|
172 if lastindent <= stopindent:
|
jpayne@68
|
173 break
|
jpayne@68
|
174 lines.reverse()
|
jpayne@68
|
175 return lines, lastindent
|
jpayne@68
|
176
|
jpayne@68
|
177 def update_code_context(self):
|
jpayne@68
|
178 """Update context information and lines visible in the context pane.
|
jpayne@68
|
179
|
jpayne@68
|
180 No update is done if the text hasn't been scrolled. If the text
|
jpayne@68
|
181 was scrolled, the lines that should be shown in the context will
|
jpayne@68
|
182 be retrieved and the context area will be updated with the code,
|
jpayne@68
|
183 up to the number of maxlines.
|
jpayne@68
|
184 """
|
jpayne@68
|
185 new_topvisible = self.editwin.getlineno("@0,0")
|
jpayne@68
|
186 if self.topvisible == new_topvisible: # Haven't scrolled.
|
jpayne@68
|
187 return
|
jpayne@68
|
188 if self.topvisible < new_topvisible: # Scroll down.
|
jpayne@68
|
189 lines, lastindent = self.get_context(new_topvisible,
|
jpayne@68
|
190 self.topvisible)
|
jpayne@68
|
191 # Retain only context info applicable to the region
|
jpayne@68
|
192 # between topvisible and new_topvisible.
|
jpayne@68
|
193 while self.info[-1][1] >= lastindent:
|
jpayne@68
|
194 del self.info[-1]
|
jpayne@68
|
195 else: # self.topvisible > new_topvisible: # Scroll up.
|
jpayne@68
|
196 stopindent = self.info[-1][1] + 1
|
jpayne@68
|
197 # Retain only context info associated
|
jpayne@68
|
198 # with lines above new_topvisible.
|
jpayne@68
|
199 while self.info[-1][0] >= new_topvisible:
|
jpayne@68
|
200 stopindent = self.info[-1][1]
|
jpayne@68
|
201 del self.info[-1]
|
jpayne@68
|
202 lines, lastindent = self.get_context(new_topvisible,
|
jpayne@68
|
203 self.info[-1][0]+1,
|
jpayne@68
|
204 stopindent)
|
jpayne@68
|
205 self.info.extend(lines)
|
jpayne@68
|
206 self.topvisible = new_topvisible
|
jpayne@68
|
207 # Last context_depth context lines.
|
jpayne@68
|
208 context_strings = [x[2] for x in self.info[-self.context_depth:]]
|
jpayne@68
|
209 showfirst = 0 if context_strings[0] else 1
|
jpayne@68
|
210 # Update widget.
|
jpayne@68
|
211 self.context['height'] = len(context_strings) - showfirst
|
jpayne@68
|
212 self.context['state'] = 'normal'
|
jpayne@68
|
213 self.context.delete('1.0', 'end')
|
jpayne@68
|
214 self.context.insert('end', '\n'.join(context_strings[showfirst:]))
|
jpayne@68
|
215 self.context['state'] = 'disabled'
|
jpayne@68
|
216
|
jpayne@68
|
217 def jumptoline(self, event=None):
|
jpayne@68
|
218 "Show clicked context line at top of editor."
|
jpayne@68
|
219 lines = len(self.info)
|
jpayne@68
|
220 if lines == 1: # No context lines are showing.
|
jpayne@68
|
221 newtop = 1
|
jpayne@68
|
222 else:
|
jpayne@68
|
223 # Line number clicked.
|
jpayne@68
|
224 contextline = int(float(self.context.index('insert')))
|
jpayne@68
|
225 # Lines not displayed due to maxlines.
|
jpayne@68
|
226 offset = max(1, lines - self.context_depth) - 1
|
jpayne@68
|
227 newtop = self.info[offset + contextline][0]
|
jpayne@68
|
228 self.text.yview(f'{newtop}.0')
|
jpayne@68
|
229 self.update_code_context()
|
jpayne@68
|
230
|
jpayne@68
|
231 def timer_event(self):
|
jpayne@68
|
232 "Event on editor text widget triggered every UPDATEINTERVAL ms."
|
jpayne@68
|
233 if self.context is not None:
|
jpayne@68
|
234 self.update_code_context()
|
jpayne@68
|
235 self.t1 = self.text.after(self.UPDATEINTERVAL, self.timer_event)
|
jpayne@68
|
236
|
jpayne@68
|
237 def update_font(self):
|
jpayne@68
|
238 if self.context is not None:
|
jpayne@68
|
239 font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
|
jpayne@68
|
240 self.context['font'] = font
|
jpayne@68
|
241
|
jpayne@68
|
242 def update_highlight_colors(self):
|
jpayne@68
|
243 if self.context is not None:
|
jpayne@68
|
244 colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'context')
|
jpayne@68
|
245 self.context['background'] = colors['background']
|
jpayne@68
|
246 self.context['foreground'] = colors['foreground']
|
jpayne@68
|
247
|
jpayne@68
|
248 if self.cell00 is not None:
|
jpayne@68
|
249 line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(),
|
jpayne@68
|
250 'linenumber')
|
jpayne@68
|
251 self.cell00.config(bg=line_number_colors['background'])
|
jpayne@68
|
252
|
jpayne@68
|
253
|
jpayne@68
|
254 CodeContext.reload()
|
jpayne@68
|
255
|
jpayne@68
|
256
|
jpayne@68
|
257 if __name__ == "__main__":
|
jpayne@68
|
258 from unittest import main
|
jpayne@68
|
259 main('idlelib.idle_test.test_codecontext', verbosity=2, exit=False)
|
jpayne@68
|
260
|
jpayne@68
|
261 # Add htest.
|