blob: 018c368f421c6c751c894cdf78b117b127ba4ff6 [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"""
Tal Einat15d38612021-04-29 01:27:55 +03004import contextlib
Tal Einat7123ea02019-07-23 15:22:11 +03005import functools
6import itertools
7
8import tkinter as tk
Tal Einat15d38612021-04-29 01:27:55 +03009from tkinter.font import Font
Tal Einat7123ea02019-07-23 15:22:11 +030010from idlelib.config import idleConf
11from idlelib.delegator import Delegator
Tal Einatb43cc312021-05-03 05:27:38 +030012from idlelib import macosx
Tal Einat7123ea02019-07-23 15:22:11 +030013
14
Tal Einat15d38612021-04-29 01:27:55 +030015def get_lineno(text, index):
16 """Return the line number of an index in a Tk text widget."""
Tal Einatb43cc312021-05-03 05:27:38 +030017 text_index = text.index(index)
18 return int(float(text_index)) if text_index else None
Tal Einat7123ea02019-07-23 15:22:11 +030019
20
Tal Einat15d38612021-04-29 01:27:55 +030021def get_end_linenumber(text):
22 """Return the number of the last line in a Tk text widget."""
23 return get_lineno(text, 'end-1c')
24
25
26def get_displaylines(text, index):
27 """Display height, in lines, of a logical line in a Tk text widget."""
28 res = text.count(f"{index} linestart",
29 f"{index} lineend",
30 "displaylines")
31 return res[0] if res else 0
32
Tal Einat7123ea02019-07-23 15:22:11 +030033def get_widget_padding(widget):
34 """Get the total padding of a Tk widget, including its border."""
35 # TODO: use also in codecontext.py
36 manager = widget.winfo_manager()
37 if manager == 'pack':
38 info = widget.pack_info()
39 elif manager == 'grid':
40 info = widget.grid_info()
41 else:
42 raise ValueError(f"Unsupported geometry manager: {manager}")
43
44 # All values are passed through getint(), since some
45 # values may be pixel objects, which can't simply be added to ints.
46 padx = sum(map(widget.tk.getint, [
47 info['padx'],
48 widget.cget('padx'),
49 widget.cget('border'),
50 ]))
51 pady = sum(map(widget.tk.getint, [
52 info['pady'],
53 widget.cget('pady'),
54 widget.cget('border'),
55 ]))
56 return padx, pady
57
58
Tal Einat15d38612021-04-29 01:27:55 +030059@contextlib.contextmanager
60def temp_enable_text_widget(text):
61 text.configure(state=tk.NORMAL)
62 try:
63 yield
64 finally:
65 text.configure(state=tk.DISABLED)
66
67
Tal Einat7123ea02019-07-23 15:22:11 +030068class BaseSideBar:
Tal Einat15d38612021-04-29 01:27:55 +030069 """A base class for sidebars using Text."""
Tal Einat7123ea02019-07-23 15:22:11 +030070 def __init__(self, editwin):
71 self.editwin = editwin
72 self.parent = editwin.text_frame
73 self.text = editwin.text
74
Tal Einatb43cc312021-05-03 05:27:38 +030075 self.is_shown = False
76
77 self.main_widget = self.init_widgets()
78
79 self.bind_events()
80
Tal Einat7123ea02019-07-23 15:22:11 +030081 self.update_font()
82 self.update_colors()
83
Tal Einatb43cc312021-05-03 05:27:38 +030084 def init_widgets(self):
85 """Initialize the sidebar's widgets, returning the main widget."""
86 raise NotImplementedError
Tal Einat7123ea02019-07-23 15:22:11 +030087
88 def update_font(self):
89 """Update the sidebar text font, usually after config changes."""
Tal Einatb43cc312021-05-03 05:27:38 +030090 raise NotImplementedError
Tal Einat7123ea02019-07-23 15:22:11 +030091
92 def update_colors(self):
93 """Update the sidebar text colors, usually after config changes."""
Tal Einatb43cc312021-05-03 05:27:38 +030094 raise NotImplementedError
Tal Einat7123ea02019-07-23 15:22:11 +030095
Tal Einatb43cc312021-05-03 05:27:38 +030096 def grid(self):
97 """Layout the widget, always using grid layout."""
98 raise NotImplementedError
Tal Einat7123ea02019-07-23 15:22:11 +030099
100 def show_sidebar(self):
101 if not self.is_shown:
Tal Einatb43cc312021-05-03 05:27:38 +0300102 self.grid()
Tal Einat7123ea02019-07-23 15:22:11 +0300103 self.is_shown = True
104
105 def hide_sidebar(self):
106 if self.is_shown:
Tal Einatb43cc312021-05-03 05:27:38 +0300107 self.main_widget.grid_forget()
Tal Einat7123ea02019-07-23 15:22:11 +0300108 self.is_shown = False
109
Tal Einatb43cc312021-05-03 05:27:38 +0300110 def yscroll_event(self, *args, **kwargs):
111 """Hook for vertical scrolling for sub-classes to override."""
112 raise NotImplementedError
113
Tal Einat7123ea02019-07-23 15:22:11 +0300114 def redirect_yscroll_event(self, *args, **kwargs):
115 """Redirect vertical scrolling to the main editor text widget.
116
117 The scroll bar is also updated.
118 """
119 self.editwin.vbar.set(*args)
Tal Einatb43cc312021-05-03 05:27:38 +0300120 return self.yscroll_event(*args, **kwargs)
Tal Einat7123ea02019-07-23 15:22:11 +0300121
122 def redirect_focusin_event(self, event):
123 """Redirect focus-in events to the main editor text widget."""
124 self.text.focus_set()
125 return 'break'
126
127 def redirect_mousebutton_event(self, event, event_name):
128 """Redirect mouse button events to the main editor text widget."""
129 self.text.focus_set()
130 self.text.event_generate(event_name, x=0, y=event.y)
131 return 'break'
132
133 def redirect_mousewheel_event(self, event):
134 """Redirect mouse wheel events to the editwin text widget."""
135 self.text.event_generate('<MouseWheel>',
136 x=0, y=event.y, delta=event.delta)
137 return 'break'
138
Tal Einatb43cc312021-05-03 05:27:38 +0300139 def bind_events(self):
140 self.text['yscrollcommand'] = self.redirect_yscroll_event
141
142 # Ensure focus is always redirected to the main editor text widget.
143 self.main_widget.bind('<FocusIn>', self.redirect_focusin_event)
144
145 # Redirect mouse scrolling to the main editor text widget.
146 #
147 # Note that without this, scrolling with the mouse only scrolls
148 # the line numbers.
149 self.main_widget.bind('<MouseWheel>', self.redirect_mousewheel_event)
150
151 # Redirect mouse button events to the main editor text widget,
152 # except for the left mouse button (1).
153 #
154 # Note: X-11 sends Button-4 and Button-5 events for the scroll wheel.
155 def bind_mouse_event(event_name, target_event_name):
156 handler = functools.partial(self.redirect_mousebutton_event,
157 event_name=target_event_name)
158 self.main_widget.bind(event_name, handler)
159
160 for button in [2, 3, 4, 5]:
161 for event_name in (f'<Button-{button}>',
162 f'<ButtonRelease-{button}>',
163 f'<B{button}-Motion>',
164 ):
165 bind_mouse_event(event_name, target_event_name=event_name)
166
167 # Convert double- and triple-click events to normal click events,
168 # since event_generate() doesn't allow generating such events.
169 for event_name in (f'<Double-Button-{button}>',
170 f'<Triple-Button-{button}>',
171 ):
172 bind_mouse_event(event_name,
173 target_event_name=f'<Button-{button}>')
174
175 # start_line is set upon <Button-1> to allow selecting a range of rows
176 # by dragging. It is cleared upon <ButtonRelease-1>.
177 start_line = None
178
179 # last_y is initially set upon <B1-Leave> and is continuously updated
180 # upon <B1-Motion>, until <B1-Enter> or the mouse button is released.
181 # It is used in text_auto_scroll(), which is called repeatedly and
182 # does have a mouse event available.
183 last_y = None
184
185 # auto_scrolling_after_id is set whenever text_auto_scroll is
186 # scheduled via .after(). It is used to stop the auto-scrolling
187 # upon <B1-Enter>, as well as to avoid scheduling the function several
188 # times in parallel.
189 auto_scrolling_after_id = None
190
191 def drag_update_selection_and_insert_mark(y_coord):
192 """Helper function for drag and selection event handlers."""
193 lineno = get_lineno(self.text, f"@0,{y_coord}")
194 a, b = sorted([start_line, lineno])
195 self.text.tag_remove("sel", "1.0", "end")
196 self.text.tag_add("sel", f"{a}.0", f"{b+1}.0")
197 self.text.mark_set("insert",
198 f"{lineno if lineno == a else lineno + 1}.0")
199
200 def b1_mousedown_handler(event):
201 nonlocal start_line
202 nonlocal last_y
203 start_line = int(float(self.text.index(f"@0,{event.y}")))
204 last_y = event.y
205
206 drag_update_selection_and_insert_mark(event.y)
207 self.main_widget.bind('<Button-1>', b1_mousedown_handler)
208
209 def b1_mouseup_handler(event):
210 # On mouse up, we're no longer dragging. Set the shared persistent
211 # variables to None to represent this.
212 nonlocal start_line
213 nonlocal last_y
214 start_line = None
215 last_y = None
216 self.text.event_generate('<ButtonRelease-1>', x=0, y=event.y)
217 self.main_widget.bind('<ButtonRelease-1>', b1_mouseup_handler)
218
219 def b1_drag_handler(event):
220 nonlocal last_y
221 if last_y is None: # i.e. if not currently dragging
222 return
223 last_y = event.y
224 drag_update_selection_and_insert_mark(event.y)
225 self.main_widget.bind('<B1-Motion>', b1_drag_handler)
226
227 def text_auto_scroll():
228 """Mimic Text auto-scrolling when dragging outside of it."""
229 # See: https://github.com/tcltk/tk/blob/064ff9941b4b80b85916a8afe86a6c21fd388b54/library/text.tcl#L670
230 nonlocal auto_scrolling_after_id
231 y = last_y
232 if y is None:
233 self.main_widget.after_cancel(auto_scrolling_after_id)
234 auto_scrolling_after_id = None
235 return
236 elif y < 0:
237 self.text.yview_scroll(-1 + y, 'pixels')
238 drag_update_selection_and_insert_mark(y)
239 elif y > self.main_widget.winfo_height():
240 self.text.yview_scroll(1 + y - self.main_widget.winfo_height(),
241 'pixels')
242 drag_update_selection_and_insert_mark(y)
243 auto_scrolling_after_id = \
244 self.main_widget.after(50, text_auto_scroll)
245
246 def b1_leave_handler(event):
247 # Schedule the initial call to text_auto_scroll(), if not already
248 # scheduled.
249 nonlocal auto_scrolling_after_id
250 if auto_scrolling_after_id is None:
251 nonlocal last_y
252 last_y = event.y
253 auto_scrolling_after_id = \
254 self.main_widget.after(0, text_auto_scroll)
255 self.main_widget.bind('<B1-Leave>', b1_leave_handler)
256
257 def b1_enter_handler(event):
258 # Cancel the scheduling of text_auto_scroll(), if it exists.
259 nonlocal auto_scrolling_after_id
260 if auto_scrolling_after_id is not None:
261 self.main_widget.after_cancel(auto_scrolling_after_id)
262 auto_scrolling_after_id = None
263 self.main_widget.bind('<B1-Enter>', b1_enter_handler)
264
Tal Einat7123ea02019-07-23 15:22:11 +0300265
266class EndLineDelegator(Delegator):
Tal Einat15d38612021-04-29 01:27:55 +0300267 """Generate callbacks with the current end line number.
268
269 The provided callback is called after every insert and delete.
270 """
Tal Einat7123ea02019-07-23 15:22:11 +0300271 def __init__(self, changed_callback):
Tal Einat7123ea02019-07-23 15:22:11 +0300272 Delegator.__init__(self)
273 self.changed_callback = changed_callback
274
275 def insert(self, index, chars, tags=None):
276 self.delegate.insert(index, chars, tags)
277 self.changed_callback(get_end_linenumber(self.delegate))
278
279 def delete(self, index1, index2=None):
280 self.delegate.delete(index1, index2)
281 self.changed_callback(get_end_linenumber(self.delegate))
282
283
284class LineNumbers(BaseSideBar):
285 """Line numbers support for editor windows."""
286 def __init__(self, editwin):
Tal Einatb43cc312021-05-03 05:27:38 +0300287 super().__init__(editwin)
Tal Einat7123ea02019-07-23 15:22:11 +0300288
289 end_line_delegator = EndLineDelegator(self.update_sidebar_text)
290 # Insert the delegator after the undo delegator, so that line numbers
291 # are properly updated after undo and redo actions.
Tal Einatb43cc312021-05-03 05:27:38 +0300292 self.editwin.per.insertfilterafter(end_line_delegator,
Tal Einat15d38612021-04-29 01:27:55 +0300293 after=self.editwin.undo)
Tal Einat7123ea02019-07-23 15:22:11 +0300294
Tal Einatb43cc312021-05-03 05:27:38 +0300295 def init_widgets(self):
296 _padx, pady = get_widget_padding(self.text)
297 self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE,
298 padx=2, pady=pady,
299 borderwidth=0, highlightthickness=0)
300 self.sidebar_text.config(state=tk.DISABLED)
Tal Einat7123ea02019-07-23 15:22:11 +0300301
Tal Einatb43cc312021-05-03 05:27:38 +0300302 self.prev_end = 1
303 self._sidebar_width_type = type(self.sidebar_text['width'])
304 with temp_enable_text_widget(self.sidebar_text):
305 self.sidebar_text.insert('insert', '1', 'linenumber')
306 self.sidebar_text.config(takefocus=False, exportselection=False)
307 self.sidebar_text.tag_config('linenumber', justify=tk.RIGHT)
Tal Einat7123ea02019-07-23 15:22:11 +0300308
Tal Einatb43cc312021-05-03 05:27:38 +0300309 end = get_end_linenumber(self.text)
310 self.update_sidebar_text(end)
Tal Einat7123ea02019-07-23 15:22:11 +0300311
Tal Einatb43cc312021-05-03 05:27:38 +0300312 return self.sidebar_text
Tal Einat7123ea02019-07-23 15:22:11 +0300313
Tal Einatb43cc312021-05-03 05:27:38 +0300314 def grid(self):
315 self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW)
Tal Einat7123ea02019-07-23 15:22:11 +0300316
Tal Einatb43cc312021-05-03 05:27:38 +0300317 def update_font(self):
318 font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
319 self.sidebar_text['font'] = font
Tal Einat7123ea02019-07-23 15:22:11 +0300320
321 def update_colors(self):
322 """Update the sidebar text colors, usually after config changes."""
323 colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
Tal Einatb43cc312021-05-03 05:27:38 +0300324 foreground = colors['foreground']
325 background = colors['background']
326 self.sidebar_text.config(
327 fg=foreground, bg=background,
328 selectforeground=foreground, selectbackground=background,
329 inactiveselectbackground=background,
330 )
Tal Einat7123ea02019-07-23 15:22:11 +0300331
332 def update_sidebar_text(self, end):
333 """
334 Perform the following action:
335 Each line sidebar_text contains the linenumber for that line
336 Synchronize with editwin.text so that both sidebar_text and
337 editwin.text contain the same number of lines"""
338 if end == self.prev_end:
339 return
340
341 width_difference = len(str(end)) - len(str(self.prev_end))
342 if width_difference:
343 cur_width = int(float(self.sidebar_text['width']))
344 new_width = cur_width + width_difference
345 self.sidebar_text['width'] = self._sidebar_width_type(new_width)
346
Tal Einat15d38612021-04-29 01:27:55 +0300347 with temp_enable_text_widget(self.sidebar_text):
348 if end > self.prev_end:
349 new_text = '\n'.join(itertools.chain(
350 [''],
351 map(str, range(self.prev_end + 1, end + 1)),
352 ))
353 self.sidebar_text.insert(f'end -1c', new_text, 'linenumber')
354 else:
355 self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c')
Tal Einat7123ea02019-07-23 15:22:11 +0300356
357 self.prev_end = end
358
Tal Einatb43cc312021-05-03 05:27:38 +0300359 def yscroll_event(self, *args, **kwargs):
360 self.sidebar_text.yview_moveto(args[0])
361 return 'break'
362
Tal Einat7123ea02019-07-23 15:22:11 +0300363
Tal Einat15d38612021-04-29 01:27:55 +0300364class WrappedLineHeightChangeDelegator(Delegator):
365 def __init__(self, callback):
366 """
367 callback - Callable, will be called when an insert, delete or replace
368 action on the text widget may require updating the shell
369 sidebar.
370 """
371 Delegator.__init__(self)
372 self.callback = callback
373
374 def insert(self, index, chars, tags=None):
375 is_single_line = '\n' not in chars
376 if is_single_line:
377 before_displaylines = get_displaylines(self, index)
378
379 self.delegate.insert(index, chars, tags)
380
381 if is_single_line:
382 after_displaylines = get_displaylines(self, index)
383 if after_displaylines == before_displaylines:
384 return # no need to update the sidebar
385
386 self.callback()
387
388 def delete(self, index1, index2=None):
389 if index2 is None:
390 index2 = index1 + "+1c"
391 is_single_line = get_lineno(self, index1) == get_lineno(self, index2)
392 if is_single_line:
393 before_displaylines = get_displaylines(self, index1)
394
395 self.delegate.delete(index1, index2)
396
397 if is_single_line:
398 after_displaylines = get_displaylines(self, index1)
399 if after_displaylines == before_displaylines:
400 return # no need to update the sidebar
401
402 self.callback()
403
404
Tal Einatb43cc312021-05-03 05:27:38 +0300405class ShellSidebar(BaseSideBar):
Tal Einat15d38612021-04-29 01:27:55 +0300406 """Sidebar for the PyShell window, for prompts etc."""
407 def __init__(self, editwin):
Tal Einatb43cc312021-05-03 05:27:38 +0300408 self.canvas = None
409 self.line_prompts = {}
Tal Einat15d38612021-04-29 01:27:55 +0300410
Tal Einatb43cc312021-05-03 05:27:38 +0300411 super().__init__(editwin)
Tal Einat15d38612021-04-29 01:27:55 +0300412
413 change_delegator = \
414 WrappedLineHeightChangeDelegator(self.change_callback)
Tal Einat15d38612021-04-29 01:27:55 +0300415 # Insert the TextChangeDelegator after the last delegator, so that
416 # the sidebar reflects final changes to the text widget contents.
417 d = self.editwin.per.top
418 if d.delegate is not self.text:
419 while d.delegate is not self.editwin.per.bottom:
420 d = d.delegate
421 self.editwin.per.insertfilterafter(change_delegator, after=d)
422
Tal Einat15d38612021-04-29 01:27:55 +0300423 self.is_shown = True
424
Tal Einatb43cc312021-05-03 05:27:38 +0300425 def init_widgets(self):
426 self.canvas = tk.Canvas(self.parent, width=30,
427 borderwidth=0, highlightthickness=0,
428 takefocus=False)
429 self.update_sidebar()
430 self.grid()
431 return self.canvas
432
433 def bind_events(self):
434 super().bind_events()
435
436 self.main_widget.bind(
437 # AquaTk defines <2> as the right button, not <3>.
438 "<Button-2>" if macosx.isAquaTk() else "<Button-3>",
439 self.context_menu_event,
440 )
441
442 def context_menu_event(self, event):
443 rmenu = tk.Menu(self.main_widget, tearoff=0)
444 has_selection = bool(self.text.tag_nextrange('sel', '1.0'))
445 def mkcmd(eventname):
446 return lambda: self.text.event_generate(eventname)
447 rmenu.add_command(label='Copy',
448 command=mkcmd('<<copy>>'),
449 state='normal' if has_selection else 'disabled')
450 rmenu.add_command(label='Copy with prompts',
451 command=mkcmd('<<copy-with-prompts>>'),
452 state='normal' if has_selection else 'disabled')
453 rmenu.tk_popup(event.x_root, event.y_root)
454 return "break"
455
456 def grid(self):
457 self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0)
458
Tal Einat15d38612021-04-29 01:27:55 +0300459 def change_callback(self):
460 if self.is_shown:
461 self.update_sidebar()
462
463 def update_sidebar(self):
464 text = self.text
465 text_tagnames = text.tag_names
466 canvas = self.canvas
Tal Einatb43cc312021-05-03 05:27:38 +0300467 line_prompts = self.line_prompts = {}
Tal Einat15d38612021-04-29 01:27:55 +0300468
469 canvas.delete(tk.ALL)
470
471 index = text.index("@0,0")
472 if index.split('.', 1)[1] != '0':
473 index = text.index(f'{index}+1line linestart')
474 while True:
475 lineinfo = text.dlineinfo(index)
476 if lineinfo is None:
477 break
478 y = lineinfo[1]
479 prev_newline_tagnames = text_tagnames(f"{index} linestart -1c")
480 prompt = (
481 '>>>' if "console" in prev_newline_tagnames else
482 '...' if "stdin" in prev_newline_tagnames else
483 None
484 )
485 if prompt:
486 canvas.create_text(2, y, anchor=tk.NW, text=prompt,
487 font=self.font, fill=self.colors[0])
Tal Einatb43cc312021-05-03 05:27:38 +0300488 lineno = get_lineno(text, index)
489 line_prompts[lineno] = prompt
Tal Einat15d38612021-04-29 01:27:55 +0300490 index = text.index(f'{index}+1line')
491
492 def yscroll_event(self, *args, **kwargs):
493 """Redirect vertical scrolling to the main editor text widget.
494
495 The scroll bar is also updated.
496 """
Tal Einat15d38612021-04-29 01:27:55 +0300497 self.change_callback()
498 return 'break'
499
500 def update_font(self):
501 """Update the sidebar text font, usually after config changes."""
502 font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
503 tk_font = Font(self.text, font=font)
504 char_width = max(tk_font.measure(char) for char in ['>', '.'])
505 self.canvas.configure(width=char_width * 3 + 4)
Tal Einat15d38612021-04-29 01:27:55 +0300506 self.font = font
507 self.change_callback()
508
509 def update_colors(self):
510 """Update the sidebar text colors, usually after config changes."""
511 linenumbers_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
512 prompt_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'console')
Tal Einatb43cc312021-05-03 05:27:38 +0300513 foreground = prompt_colors['foreground']
514 background = linenumbers_colors['background']
Tal Einat15d38612021-04-29 01:27:55 +0300515 self.colors = (foreground, background)
Tal Einatb43cc312021-05-03 05:27:38 +0300516 self.canvas.configure(background=background)
Tal Einat15d38612021-04-29 01:27:55 +0300517 self.change_callback()
518
Tal Einat15d38612021-04-29 01:27:55 +0300519
Tal Einat7123ea02019-07-23 15:22:11 +0300520def _linenumbers_drag_scrolling(parent): # htest #
521 from idlelib.idle_test.test_sidebar import Dummy_editwin
522
523 toplevel = tk.Toplevel(parent)
524 text_frame = tk.Frame(toplevel)
525 text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
526 text_frame.rowconfigure(1, weight=1)
527 text_frame.columnconfigure(1, weight=1)
528
529 font = idleConf.GetFont(toplevel, 'main', 'EditorWindow')
530 text = tk.Text(text_frame, width=80, height=24, wrap=tk.NONE, font=font)
531 text.grid(row=1, column=1, sticky=tk.NSEW)
532
533 editwin = Dummy_editwin(text)
534 editwin.vbar = tk.Scrollbar(text_frame)
535
536 linenumbers = LineNumbers(editwin)
537 linenumbers.show_sidebar()
538
539 text.insert('1.0', '\n'.join('a'*i for i in range(1, 101)))
540
541
542if __name__ == '__main__':
543 from unittest import main
544 main('idlelib.idle_test.test_sidebar', verbosity=2, exit=False)
545
546 from idlelib.idle_test.htest import run
547 run(_linenumbers_drag_scrolling)