Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bpo-36390: IDLE: Combine region formatting methods. #12481

Merged
merged 9 commits into from Jul 17, 2019
120 changes: 10 additions & 110 deletions Lib/idlelib/editor.py
Expand Up @@ -55,6 +55,7 @@ class EditorWindow(object):
from idlelib.codecontext import CodeContext
from idlelib.paragraph import FormatParagraph
from idlelib.parenmatch import ParenMatch
from idlelib.formatregion import FormatRegion
from idlelib.rstrip import Rstrip
from idlelib.squeezer import Squeezer
from idlelib.zoomheight import ZoomHeight
Expand Down Expand Up @@ -170,13 +171,14 @@ def __init__(self, flist=None, filename=None, key=None, root=None):
text.bind("<<smart-backspace>>",self.smart_backspace_event)
text.bind("<<newline-and-indent>>",self.newline_and_indent_event)
text.bind("<<smart-indent>>",self.smart_indent_event)
text.bind("<<indent-region>>",self.indent_region_event)
text.bind("<<dedent-region>>",self.dedent_region_event)
text.bind("<<comment-region>>",self.comment_region_event)
text.bind("<<uncomment-region>>",self.uncomment_region_event)
text.bind("<<tabify-region>>",self.tabify_region_event)
text.bind("<<untabify-region>>",self.untabify_region_event)
text.bind("<<toggle-tabs>>",self.toggle_tabs_event)
self.fregion = fregion = self.FormatRegion(self)
text.bind("<<indent-region>>", fregion.indent_region_event)
text.bind("<<dedent-region>>", fregion.dedent_region_event)
text.bind("<<comment-region>>", fregion.comment_region_event)
text.bind("<<uncomment-region>>", fregion.uncomment_region_event)
text.bind("<<tabify-region>>", fregion.tabify_region_event)
text.bind("<<untabify-region>>", fregion.untabify_region_event)
text.bind("<<toggle-tabs>>", self.toggle_tabs_event)
text.bind("<<change-indentwidth>>",self.change_indentwidth_event)
text.bind("<Left>", self.move_at_edge_if_selection(0))
text.bind("<Right>", self.move_at_edge_if_selection(1))
Expand Down Expand Up @@ -1277,7 +1279,7 @@ def smart_indent_event(self, event):
try:
if first and last:
if index2line(first) != index2line(last):
return self.indent_region_event(event)
return self.fregion.indent_region_event(event)
text.delete(first, last)
text.mark_set("insert", first)
prefix = text.get("insert linestart", "insert")
Expand Down Expand Up @@ -1410,72 +1412,6 @@ def inner(offset, _startindex=startindex,
return _icis(_startindex + "+%dc" % offset)
return inner

def indent_region_event(self, event):
head, tail, chars, lines = self.get_region()
for pos in range(len(lines)):
line = lines[pos]
if line:
raw, effective = get_line_indent(line, self.tabwidth)
effective = effective + self.indentwidth
lines[pos] = self._make_blanks(effective) + line[raw:]
self.set_region(head, tail, chars, lines)
return "break"

def dedent_region_event(self, event):
head, tail, chars, lines = self.get_region()
for pos in range(len(lines)):
line = lines[pos]
if line:
raw, effective = get_line_indent(line, self.tabwidth)
effective = max(effective - self.indentwidth, 0)
lines[pos] = self._make_blanks(effective) + line[raw:]
self.set_region(head, tail, chars, lines)
return "break"

def comment_region_event(self, event):
head, tail, chars, lines = self.get_region()
for pos in range(len(lines) - 1):
line = lines[pos]
lines[pos] = '##' + line
self.set_region(head, tail, chars, lines)
return "break"

def uncomment_region_event(self, event):
head, tail, chars, lines = self.get_region()
for pos in range(len(lines)):
line = lines[pos]
if not line:
continue
if line[:2] == '##':
line = line[2:]
elif line[:1] == '#':
line = line[1:]
lines[pos] = line
self.set_region(head, tail, chars, lines)
return "break"

def tabify_region_event(self, event):
head, tail, chars, lines = self.get_region()
tabwidth = self._asktabwidth()
if tabwidth is None: return
for pos in range(len(lines)):
line = lines[pos]
if line:
raw, effective = get_line_indent(line, tabwidth)
ntabs, nspaces = divmod(effective, tabwidth)
lines[pos] = '\t' * ntabs + ' ' * nspaces + line[raw:]
self.set_region(head, tail, chars, lines)
return "break"

def untabify_region_event(self, event):
head, tail, chars, lines = self.get_region()
tabwidth = self._asktabwidth()
if tabwidth is None: return
for pos in range(len(lines)):
lines[pos] = lines[pos].expandtabs(tabwidth)
self.set_region(head, tail, chars, lines)
return "break"

def toggle_tabs_event(self, event):
if self.askyesno(
"Toggle tabs",
Expand Down Expand Up @@ -1510,33 +1446,6 @@ def change_indentwidth_event(self, event):
self.indentwidth = new
return "break"

def get_region(self):
text = self.text
first, last = self.get_selection_indices()
if first and last:
head = text.index(first + " linestart")
tail = text.index(last + "-1c lineend +1c")
else:
head = text.index("insert linestart")
tail = text.index("insert lineend +1c")
chars = text.get(head, tail)
lines = chars.split("\n")
return head, tail, chars, lines

def set_region(self, head, tail, chars, lines):
text = self.text
newchars = "\n".join(lines)
if newchars == chars:
text.bell()
return
text.tag_remove("sel", "1.0", "end")
text.mark_set("insert", head)
text.undo_block_start()
text.delete(head, tail)
text.insert(head, newchars)
text.undo_block_stop()
text.tag_add("sel", head, "insert")

# Make string that displays as n leading blanks.

def _make_blanks(self, n):
Expand All @@ -1558,15 +1467,6 @@ def reindent_to(self, column):
text.insert("insert", self._make_blanks(column))
text.undo_block_stop()

def _asktabwidth(self):
return self.askinteger(
"Tab width",
"Columns per tab? (2-16)",
parent=self.text,
initialvalue=self.indentwidth,
minvalue=2,
maxvalue=16)

# Guess indentwidth from text content.
# Return guessed indentwidth. This should not be believed unless
# it's in a reasonable range (e.g., it will be 0 if no indented
Expand Down
169 changes: 169 additions & 0 deletions Lib/idlelib/formatregion.py
@@ -0,0 +1,169 @@
"""Format a selected region of text.

Several formatting options are available:
indent, deindent, comment, uncomment, tabify, and untabify.
"""
import re
from tkinter.simpledialog import askinteger


# Temporary copy from editor.py; importing it would cause an import cycle.
# TODO: Move the definition to a common location that both modules can import.
_line_indent_re = re.compile(r'[ \t]*')
def get_line_indent(line, tabwidth):
"""Return a line's indentation as (# chars, effective # of spaces).

The effective # of spaces is the length after properly "expanding"
the tabs into spaces, as done by str.expandtabs(tabwidth).
"""
m = _line_indent_re.match(line)
return m.end(), len(m.group().expandtabs(tabwidth))


class FormatRegion:
"Format selected text."

def __init__(self, editwin):
self.editwin = editwin

def get_region(self):
"""Return line information about the selected text region.

If text is selected, the first and last indices will be
for the selection. If there is no text selected, the
indices will be the current cursor location.
terryjreedy marked this conversation as resolved.
Show resolved Hide resolved

Return a tuple containing (first index, last index,
string representation of text, list of text lines).
"""
text = self.editwin.text
first, last = self.editwin.get_selection_indices()
if first and last:
head = text.index(first + " linestart")
tail = text.index(last + "-1c lineend +1c")
else:
head = text.index("insert linestart")
tail = text.index("insert lineend +1c")
chars = text.get(head, tail)
lines = chars.split("\n")
return head, tail, chars, lines

def set_region(self, head, tail, chars, lines):
"""Replace the text between the given indices.

Args:
head: Starting index of text to replace.
tail: Ending index of text to replace.
chars: Expected to be string of current text
between head and tail.
lines: List of new lines to insert between head
and tail.
"""
text = self.editwin.text
newchars = "\n".join(lines)
if newchars == chars:
text.bell()
return
text.tag_remove("sel", "1.0", "end")
text.mark_set("insert", head)
text.undo_block_start()
text.delete(head, tail)
text.insert(head, newchars)
text.undo_block_stop()
text.tag_add("sel", head, "insert")

def indent_region_event(self, event=None):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and the other methods below have so much in common that they should probably be refactored.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, but I wanted to make this PR about removing them from editor.py, so I kept them as they were currently written. Once the code has been split from editor.py and the tests are in place, then I'll work on refactoring.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have noticed duplication also, and I agree with leaving them as-is until a follow-up.

"Indent region by indentwidth spaces."
head, tail, chars, lines = self.get_region()
for pos in range(len(lines)):
line = lines[pos]
if line:
raw, effective = get_line_indent(line, self.editwin.tabwidth)
effective = effective + self.editwin.indentwidth
lines[pos] = self.editwin._make_blanks(effective) + line[raw:]
self.set_region(head, tail, chars, lines)
return "break"

def dedent_region_event(self, event=None):
"Dedent region by indentwidth spaces."
head, tail, chars, lines = self.get_region()
for pos in range(len(lines)):
line = lines[pos]
if line:
raw, effective = get_line_indent(line, self.editwin.tabwidth)
effective = max(effective - self.editwin.indentwidth, 0)
lines[pos] = self.editwin._make_blanks(effective) + line[raw:]
self.set_region(head, tail, chars, lines)
return "break"

def comment_region_event(self, event=None):
"""Comment out each line in region.

## is appended to the beginning of each line to comment it out.
"""
head, tail, chars, lines = self.get_region()
for pos in range(len(lines) - 1):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the last line excluded?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the last line ends in a newline, then get_region() will return an empty string as the last value. For example, the the text is a = 'hello'\n, then lines contains ['a = hello', '']. Again, I didn't alter the existing code from editor.py, so if this needs to be improved, I think it should be in a subsequent PR.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with leave as is now. By manual test, the code works (almost*) correctly. Note 1. 'a\n'.splitlines() == ['a'], 'a\n'.split('\n') == ['a', '']; get_region does the latter. *Perhaps' it should do the former. *If cursor is at the end of a file, (such as when opening a new file), a new newline may be added. Adding trailing blank lines is a bug.

line = lines[pos]
lines[pos] = '##' + line
self.set_region(head, tail, chars, lines)
return "break"

def uncomment_region_event(self, event=None):
"""Uncomment each line in region.

Remove ## or # in the first positions of a line. If the comment
is not in the beginning position, this command will have no effect.
"""
head, tail, chars, lines = self.get_region()
for pos in range(len(lines)):
line = lines[pos]
if not line:
continue
if line[:2] == '##':
line = line[2:]
elif line[:1] == '#':
line = line[1:]
lines[pos] = line
self.set_region(head, tail, chars, lines)
return "break"

def tabify_region_event(self, event=None):
"Convert leading spaces to tabs for each line in selected region."
head, tail, chars, lines = self.get_region()
tabwidth = self._asktabwidth()
if tabwidth is None:
return
for pos in range(len(lines)):
line = lines[pos]
if line:
raw, effective = get_line_indent(line, tabwidth)
ntabs, nspaces = divmod(effective, tabwidth)
lines[pos] = '\t' * ntabs + ' ' * nspaces + line[raw:]
self.set_region(head, tail, chars, lines)
return "break"

def untabify_region_event(self, event=None):
"Expand tabs to spaces for each line in region."
head, tail, chars, lines = self.get_region()
tabwidth = self._asktabwidth()
if tabwidth is None:
return
for pos in range(len(lines)):
lines[pos] = lines[pos].expandtabs(tabwidth)
self.set_region(head, tail, chars, lines)
return "break"

def _asktabwidth(self):
"Return value for tab width."
return askinteger(
"Tab width",
"Columns per tab? (2-16)",
parent=self.editwin.text,
initialvalue=self.editwin.indentwidth,
minvalue=2,
maxvalue=16)


if __name__ == "__main__":
from unittest import main
main('idlelib.idle_test.test_formatregion', verbosity=2, exit=False)