blob: 8f9bf80b5260558845588d8d9f6e53de88c72154 [file] [log] [blame]
Tal Einat7123ea02019-07-23 15:22:11 +03001"""Line numbering implementation for IDLE as an extension.
2Includes BaseSideBar which can be extended for other sidebar based extensions
3"""
4import functools
5import itertools
6
7import tkinter as tk
8from idlelib.config import idleConf
9from idlelib.delegator import Delegator
10
11
12def 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
17def 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
43class 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
121class 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
142class 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
297def _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
319if __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)