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