Skip to content

Commit 347819b

Browse files
committed
Feat: Double left click to smart select meaningful selection
1 parent 7cfeb8c commit 347819b

File tree

2 files changed

+193
-0
lines changed

2 files changed

+193
-0
lines changed

Lib/idlelib/editor.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from idlelib.util import py_extensions
3131
from idlelib import window
3232
from idlelib.help import _get_dochome
33+
from idlelib.selector import SmartSelect, DisableTkDouble1Binding
3334

3435
# The default tab setting for a Text widget, in average-width characters.
3536
TK_TABWIDTH_DEFAULT = 8
@@ -157,6 +158,8 @@ def __init__(self, flist=None, filename=None, key=None, root=None):
157158
text.bind("<<del-word-left>>", self.del_word_left)
158159
text.bind("<<del-word-right>>", self.del_word_right)
159160
text.bind("<<beginning-of-line>>", self.home_callback)
161+
DisableTkDouble1Binding(root)
162+
text.bind('<Double-Button-1>', SmartSelect)
160163

161164
if flist:
162165
flist.inversedict[self] = key

Lib/idlelib/selector.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""Smart SelectThis module provides smart selection functionality for Tkinter text widgets.By analyzing the character at the cursor position and its context, itautomatically selects text units such as words, lines, code blocks, bracketpairs, strings, and more.Functions: DisableTkDouble1Binding: Disables the default double-click binding of Tkinter text widgets to prevent conflicts with custom selection logic. TextSelector: Text selection operations, providing methods to select characters, lines, and ranges. SelectBlock: Selects code blocks based on indentation, supporting multi-line selection. SmartSelect: An event handler function that triggers smart selection based on the current character (e.g., letters, spaces, quotes, brackets).Usage: Bind SmartSelect to an event of the text widget (such as <Double-Button-1>) to enable automatic selection. Suitable for Tkinter applications that require enhanced text selection experience."""import re
2+
3+
sp = lambda c: eval(c.replace('.',','))
4+
jn = lambda x,y: '%i.%i'%(x,y)
5+
lc = lambda s: jn(s.count('\n')+1,len(s)-s.rfind('\n')-1)
6+
7+
is_code = lambda s: s.split('#')[0].strip()
8+
is_blank = lambda s: not s.strip()
9+
get_indent = lambda s: re.match(r' *', s.rstrip()).end()
10+
11+
12+
def DisableTkDouble1Binding(root):
13+
tk = root.tk
14+
tk.eval('bind Text <Double-1> {catch {%W mark set insert sel.first}}') # See at: "~\tcl\tk8.6\text.tcl"
15+
16+
17+
class TextSelector:
18+
def __init__(self, text):
19+
self.text = text
20+
21+
def select(self, idx1, idx2):
22+
self.text.tag_remove('sel', '1.0', 'end')
23+
self.text.tag_add('sel', idx1, idx2)
24+
self.text.mark_set('insert', idx1)
25+
26+
def select_chars(self, n=1, n2=0):
27+
n, n2 = sorted([n, n2])
28+
self.select('current%+dc' % n, 'current%+dc' % n2)
29+
30+
def select_from_line(self, n1, n2):
31+
n2 = 'start%+dc' % n2 if isinstance(n2, int) else n2
32+
self.select('current linestart+%dc' % n1, 'current line' + n2)
33+
34+
def select_lines(self, n=1, n2=0):
35+
n, n2 = sorted([n, n2])
36+
self.select('current linestart%+dl' % n, 'current linestart%+dl' % n2)
37+
38+
39+
def FindParent(s, c1='(', c2=')'):
40+
n1 = n2 = 0
41+
for n, c in enumerate(s):
42+
n1 += c in c1
43+
n2 += c in c2
44+
if n1 and n1 == n2:
45+
return n
46+
47+
48+
def MatchSpan(r, s, n):
49+
for m in re.finditer(r, s):
50+
if m.start() <= n <= m.end():
51+
return m.span()
52+
53+
54+
def SelectBlock(text, first_line=True):
55+
patt = re.compile(r'([ \t]*)(.*?)([ \t]*)(#.*)?')
56+
57+
idx1 = 'current linestart' if first_line else 'current linestart-1l'
58+
lines = text.get(idx1, 'end').split('\n')
59+
base_indent = patt.fullmatch(lines[0]).group(1)
60+
61+
ln = -1
62+
for i, line in enumerate(lines[1:]):
63+
indent, code, space, comment = patt.fullmatch(line).groups()
64+
if code or comment:
65+
if indent > base_indent:
66+
ln = i
67+
else:
68+
break
69+
70+
ln = max(1, ln + 2 if first_line else ln + 1) # at least select one line
71+
text_sel = TextSelector(text)
72+
text_sel.select_lines(ln)
73+
74+
75+
def SmartSelect(event):
76+
text = event.widget
77+
text_sel = TextSelector(text)
78+
text.tag_remove('hit', '1.0', 'end')
79+
80+
if 'STRING' in text.tag_names('current'):
81+
st, ed = text.tag_prevrange('STRING', 'current+1c') # See at: `idlelib.squeezer.Squeezer.squeeze_current_text_event`
82+
ed2 = text.index(ed + '-1c')
83+
cur = text.index('current')
84+
if cur in (st, ed2):
85+
return text_sel.select(st, ed)
86+
87+
cur = text.index('current') # should not be 'insert', it will cause the cursor position at the beginning of the automatic selection area
88+
col = sp(cur)[1]
89+
line = text.get('current linestart', 'current lineend')
90+
91+
c = text.get('current') # charset: !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
92+
if c == ':':
93+
if is_code(line[col+1:]):
94+
text_sel.select_chars()
95+
else:
96+
SelectBlock(text)
97+
98+
elif c == ' ' or c == '\n' and col == 0:
99+
indent = get_indent(line)
100+
if col <= indent:
101+
102+
# | prev line | current line | action |
103+
# | ----------- | ------------ | ---------------- |
104+
# | blank | blank | select line |
105+
# | blank | indent | select block |
106+
# | indent | blank | select sub block |
107+
# | indent | same indent | select block |
108+
# | less indent | more indent | select sub block |
109+
# | more indent | less indent | select block |
110+
111+
prev_line = text.get('current-1l linestart', 'current-1l lineend')
112+
if not is_blank(prev_line) and (get_indent(prev_line) < indent or is_blank(line)):
113+
SelectBlock(text, False)
114+
elif not is_blank(line):
115+
SelectBlock(text)
116+
else:
117+
text_sel.select_lines()
118+
else:
119+
p1, p2 = MatchSpan(r' +', line, col)
120+
text_sel.select_from_line(p1, p2)
121+
122+
elif re.match(r'\w', c):
123+
p1, p2 = MatchSpan(r'\w+', line, col)
124+
text_sel.select_from_line(p1, p2)
125+
126+
# sometimes will cause performance issues, so return it.
127+
return
128+
129+
word = line[p1:p2]
130+
s = text.get('1.0', 'end')
131+
for m in re.finditer(r'\b%s\b' % word, s):
132+
p1, p2 = m.span()
133+
text.tag_add('hit', lc(s[:p1]), lc(s[:p2])) # should not be '1.0+nc', it will cause offseting when exist Squeezer
134+
135+
elif c == '\n':
136+
text_sel.select_lines()
137+
138+
elif c == '#':
139+
if 'COMMENT' in text.tag_names('current') and \
140+
text.index('current') == text.tag_prevrange('COMMENT', 'current+1c')[0]:
141+
p1, p2 = MatchSpan(r' *#', line, col)
142+
if p1 > 0:
143+
text_sel.select_from_line(p1, 'end')
144+
else:
145+
text_sel.select_lines()
146+
else:
147+
text_sel.select_chars()
148+
149+
elif c in '\'"`': # quote in comment or string
150+
s = text.get('current+1c', 'end')
151+
n = s.find(c)
152+
text_sel.select_chars(n + 2)
153+
154+
elif c in '([{':
155+
c1 = c
156+
c2 = ')]}'['([{'.index(c1)]
157+
s = text.get('current', 'end')
158+
n = FindParent(s, c1, c2)
159+
text_sel.select_chars(n + 1)
160+
161+
elif c in ')]}':
162+
c1 = c
163+
c2 = '([{'[')]}'.index(c1)]
164+
s = text.get('1.0', 'current+1c')
165+
n = FindParent(reversed(s), c1, c2)
166+
text_sel.select_chars(-n, 1)
167+
168+
elif c == '\\': # e.g. \n \xhh \uxxxx \Uxxxxxxxx
169+
c2 = text.get('current+1c')
170+
n = {'x': 4, 'u': 6, 'U': 10}.get(c2, 2)
171+
text_sel.select_chars(n)
172+
173+
elif c == '.':
174+
s = text.get('current+1c', 'current lineend')
175+
n = re.match(r'\s*\w*', s).end()
176+
text_sel.select_chars(n + 1)
177+
178+
elif c == ',': # e.g. foo(a, b(c, d=e), f)
179+
s = text.get('current+1c', 'end')
180+
n1 = n2 = 0
181+
for n, c1 in enumerate(s):
182+
if n1 == n2 and c1 in ',)]}':
183+
break
184+
n1 += c1 in '([{'
185+
n2 += c1 in ')]}'
186+
text_sel.select_chars(n + 1)
187+
188+
else: # charset: !$%&*+,-/;<=>?@^|~
189+
p1, p2 = MatchSpan(r'[^\w()[\]{}\'" ]+', line, col) # do not concatenate to brackets, spaces, or quotes
190+
text_sel.select_from_line(p1, p2)

0 commit comments

Comments
 (0)