I created a box-select feature for the tk.Text
widget. You can select, cut, copy, paste, drag/drop and type with column-centric behavior. I'd like any feedback that is available.
The code is pretty heavily commented, but I can give a gist of its working mechanics. Every box-selection-specific method moves, changes, deletes, copies, selects by, etc.. SelectBounds
. That's it, really. It's a bounds manager representing its data as text selection.
#begin col/row, end col/row, (width or len), height, down, right
SelectBounds = namedtuple('SelectBounds', 'bc br ec er w h dn rt')
Controls:
Box selection begins by putting the caret at the index where you want to start the selection, and pressing
Alt
+Shift
.
From there you have 3 options:LMB
+DRAG
to drag out a box-selectionLMB
at the point you want the selection to end. If you don't release you can still drag to adjust.- press
Arrow
keys in the direction that you want the box to expand/contract. (NumPad Arrows NOT supported)
Creating a box-selection of no width will produce a multiline-caret.
- You can type at the multiline-caret, and whatever you type will appear on every active line.
- Pressing
BackSpace
will perform a backspace operation on every active line.
After Making a box-selection and while the selection is still active, pressing
Shift
+Arrow
will move the entire selection in the arrow direction. This counts for multiline-carets, as well.Any selection can be drag-dropped.
- Press and hold
LMB
over a selection to grab, then drag and drop in a new location. - In the case of box-selections there is a vertical offset applied to grab and drop. If you grabbed at (ex) the third row of a column, you would have to drop where you want the third row to be.
- Press and hold
With any selection active, pressing
Left
orRight
will deselect and move the caret to the beginning (left) or end (right) of the former selectionCut, Copy and Paste work the same as you would expect and the hotkeys are no different. In or from box-select mode these are performed with column behavior.
Any method of box-selection can be performed in any direction
Features:
A faint highlight is applied to the background of the line the caret is on ~ the "active line"
Multiline-carets always have a brighter portion that reflects where the real caret is
While you are in the process of draggin a selection the real caret is revealed and joins the active line highlight in following your cursor.
Glitches:
- On very rare occassions, releasing
Alt
from the hotkey combo will result in the window gaining focus, and the text area seeming to freeze. If this happens just pressAlt
again.
Code:
tkinter-column-select (github)
import tkinter as tk, tkinter.font as tkf
from collections import namedtuple
from typing import Iterable, Any
from dataclasses import dataclass, asdict
import math, re, tempfile, os
#event.state flags
SHIFT = 0x000001
CONTROL = 0x000004
BUTTON1 = 0x000100
ALT = 0x020000
ARROWKEY = 0x040000
ALTSHIFT = ALT|SHIFT
#swatches
BG = '#181818' #text background
ACT_BG = '#1f1f28' #active line background
FG = '#CFCFEF' #all foregrounds and caret color
SEL_BG = '#383848' #select background
SDW_CT = '#68689F' #shadow caret color
#vars
INSWIDTH = 1 #caret width
INSPNT = 'insertpoint' #drop insertion point
ILWHITE = re.compile(r'[ \t]+') #inline whitespace regex
#arrows ~ for various key conditions
HARROWS = ('Left','KP_Left','Right','KP_Right')
VARROWS = ('Up','KP_Up','Down','KP_Down')
ARROWS = HARROWS+VARROWS
ALTS = ('Alt_L', 'Alt_R')
SHIFTS = ('Shift_L', 'Shift_R')
ALTSHIFTS= ALTS+SHIFTS
#default tk.Text **kwargs for this script
@dataclass
class Text_t:
font :str = '{Courier New} 14'
background :str = BG
foreground :str = FG
selectforeground:str = FG
selectbackground:str = SEL_BG
insertwidth :int = INSWIDTH
insertofftime :int = 300
insertontime :int = 600
insertbackground:str = FG #caret color
wrap :str = "none"
exportselection :int = 1
takefocus :int = 1
undo :bool = True
autoseparators :bool = True
maxundo :int = 32 #-1 for infinite
#this adds some extra generic behavior to tk.Text, and automates it's config to our purposes
#it's turned into it's own thing so we can get generic scripts out of the box-select code
class Textra(tk.Text):
#CARET POSITIONING
@property
def caret(self) -> str: return self.index('insert')
@caret.setter
def caret(self, index:str) -> None: self.mark_set('insert', index)
#TEXT
@property
def text(self) -> str: return self.get('1.0', f'{tk.END}-1c')
@text.setter
def text(self, text:str) -> None: self.delete('1.0', tk.END); self.insert('1.0', text)
#replace text
def replace_text(self, b:str, e:str, text:str) -> None:
self.delete(b, e)
self.insert(b, text)
#append text
def append_text(self, text:str) -> None:
self.insert(f'{tk.END}-1c', text)
#LINE
def dlineinfo(self, index=tk.INSERT) -> tuple:
self.update_idletasks()
return super().dlineinfo(index)
#TAGS
# No exception .start, .end
def tag_bounds(self, tag:str) -> tuple:
if (tr:=self.tag_ranges(tag)) and len(tr)>1:
return map(str,(tr[0],tr[-1]))
return None, None
#replace all instances of `tag_in` with `tag_out`
def tag_replace(self, tag_in:str, tag_out:str) -> None:
r = self.tag_ranges(tag_in)
#swap the tags
for i in range(0, len(r), 2):
self.tag_remove(tag_in , *r[i:i+2])
self.tag_add (tag_out, *r[i:i+2])
#move all instances of a tag to a new location
#acts as `tag_remove()` when `b` and/or `e` are None
def tag_move(self, tag:str, b:Iterable=None, e:Iterable=None) -> None:
x, y = self.tag_bounds(tag)
if x and y: self.tag_remove(tag, x, y)
if b and e:
x = b if isinstance(b, (list,tuple)) else (b,)
y = e if isinstance(e, (list,tuple)) else (e,)
for b, e in zip(x,y):
self.tag_add(tag, b, e)
#CONSTRUCTOR
def __init__(self, master, *args, **kwargs):
tk.Text.__init__(self, master, *args, **{**asdict(Text_t()), **kwargs})
#backbone of the entire operation
#begin col/row, end col/row, (width or len), height, down, right
SelectBounds = namedtuple('SelectBounds', 'bc br ec er w h dn rt')
#BOX-SELECT
class BoxSelectText(Textra):
#POSITIONING
#absolutely do NOT convert anything within this method to an `.index(...)`
#some of the descriptions below may not exist yet
def __bounds(self, b:str=None, e:str=None, dn:bool=None, rt:bool=None, ow:bool=False) -> SelectBounds:
if (b:=(b or self.__boxstart)) and (e:=(e or self.__boxend)):
#parse row/col positions
b_ = [*map(int, b.split('.'))]
e_ = [*map(int, e.split('.'))]
if self.__boxselect:
#row and col positions ~ min/max each
(br,er),(bc,ec) = (sorted(x) for x in zip(b_,e_))
#width, height
w, h = ec-bc, er-br
#regular selection
else:
#row and col positions ~ min/max row
(br,bc),(er,ec) = b_, e_
(br,er) = sorted((br,er))
#len, height
w, h = len(self.get(f'{br}.{bc}', f'{er}.{ec}')), er-br
#overwrite
if ow:
self.__boxstart = f'{br}.{bc}'
self.__boxend = f'{er}.{ec}'
#selection direction
dn = (b_[0]<e_[0]) if dn is None and (b_ and e_) else dn
rt = (b_[1]<e_[1]) if rt is None and (b_ and e_) else rt
#`h` is actually a count of how many lines come after the first one
return SelectBounds(bc, br, ec, er, w, h, dn, rt)
return None
#"DRAW_RECT" (essentially)
#tags __lbounds ranges and puts a multiline caret on whichever side makes sense
#`ao` determines if (b)egin(o)ffset and (e)nd(o)ffset are applied to everything(True) or just begin/end indexes(False)
def __bounds_range(self, tag, bo:int=0, eo:int=0, ao:bool=False):
#hide real caret
self['insertwidth'] = 0
if bnd:=self.__lbounds:
#precalculate begin/end offsets with (a)ll(o)ffsets
abo, aeo = int(bo*ao) , int(eo*ao)
#add offsets to begin/end indexes
abc, aec = f'{bnd.bc+abo}', f'{bnd.ec+aeo}'
#prime begin, end ranges
b,e = [], []
#get last row number
r,_ = map(int, self.index(f'{tk.END}-1c').split('.'))
for n in range(bnd.br, bnd.er+1):
#if we are not trying to exceed the last usable row
if n<=r:
#store beginning and end indexes
b.append(f'{n}.{bnd.bc+bo}')
e.append(f'{n}.{bnd.ec+eo}')
yield n, bnd.bc+abo, bnd.ec+aeo #row, begin column, end column
#create caret
i = self.__index(f'{n}.{abc}',f'{n}.{aec}',bnd.dn, bnd.rt)
self.__fauxcaret(i)
self.caret = self.__index(f'{bnd.br}.{abc}',f'{bnd.er}.{aec}', bnd.dn, bnd.rt)
self.__fauxcaret(self.caret, main=True, cfg=True) #config main faux-caret
self.set_activeline()
self.tag_move(tag, b, e) #clear and draw tag
#this is the "draw a multiline-caret" version of __bounds_range (above)
#adv puts the caret adv amount of characters from the current column. this can be negative, and will be for `BackSpace`
def __typing_range(self, adv:int):
self['insertwidth'] = 0 #hide real caret
self.__blinkreset()
for n in self.image_names(): self.delete(n) #delete faux-carets
#if there was something to cut, and adv is negative, stop the caret from retreating
if self.__cut() and adv<0: adv=0
if bnd:=self.__lbounds:
#type at multiline-caret
for n in range(bnd.br, bnd.er+1):
yield n, bnd.bc, bnd.ec, adv #row, begin, column, adv
self.__fauxcaret(f'{n}.{bnd.bc+adv}') #create caret
#begin/end indexes ~ we need this more than once
i = (f'{bnd.br}.{bnd.bc+adv}', f'{bnd.er}.{bnd.bc+adv}')
self.__lbounds = self.__bounds(*i, ow=True, dn=bnd.dn, rt=bnd.rt)
self.caret = self.__index (*i, bnd.dn, bnd.rt)
self.__fauxcaret(self.caret, main=True, cfg=True) #config main faux-caret
self.set_activeline()
self.__blink()
#arrow index
def __aindex(self, keysym:str) -> str:
r, c = map(int, self.caret.split('.'))
for k,(r2,c2) in self.__arrows.items():
if k in keysym:
r += r2; c += c2
self.caret = f'{max(1,r)}.{max(0,c)}'
return self.caret
return None
#virtual index
def __vindex(self, x:int, y:int) -> str:
#for determining how to horizontally snap caret
rt = bnd.rt if (bnd:=self.__lbounds) and not (None in bnd) else 1
#the top-left visible row,col numbers
r,c = map(int, self.index('@0,0').split('.'))
#figure out where we are virtually
r = round(y/self.__fh-0.50) + r
c = (math.floor,math.ceil)[rt](x/self.__fw) + c
#where we would be if every possible index was valid
return f'{max(r,1)}.{max(c,0)}'
#box-select faux-caret attaches to mouse cursor side of selection, and main faux-caret follows mouse cursor
def __index(self, start:str, end:str, dn:bool, rt:bool) -> str:
r, c = zip(map(int, start.split('.')), map(int, end.split('.')))
return f'{r[dn]}.{c[rt]}'
#CONSTRUCTOR
def __init__(self, master, *args, **kwargs):
Textra.__init__(self, master, *args, **kwargs)
#box-select tag
self.tag_configure('BOXSELECT' , background=self['selectbackground'])
self.tag_configure('ACTIVELINE' , background=ACT_BG)
#capture character width and height, make faux-carets for this font height
self.update_font(self['font'])
#selection insertion point
self.mark_set(INSPNT, '1.0')
self.mark_gravity(INSPNT, tk.LEFT)
#add listeners
for evt in ('KeyPress','KeyRelease','ButtonPress-1','ButtonRelease-1','Motion'):
self.bind(f'<{evt}>', self.__handler)
#features
self.__boxselect = False #select text within a rect
self.__boxcopy = False #cut/copy/paste with column behavior
self.__selgrab = False #grab any selected data
self.__seldrag = False #drag any selected data
#vars
self.__boxstart = None #box bounds start position
self.__boxend = None #box bounds end position
self.__vgrabofs = None #vertical offset from 'current' to sel.start
self.__linsert = None #last known 'insert' position ~ used while __as or __selgrab is True
self.__lbounds = None #last bounds that were applied
self.__lclipbd = '' #back-up of last clipboard data
self.__as_reset() #prime ALT+SHIFT properties
self.__blinkreset() #prime blink properties
#arrow key movement for arrow-key-box-select
self.__arrows = {'Down':( 1,0),'Right':(0, 1),
'Up' :(-1,0),'Left' :(0,-1)}
#hijack tcl commands stream so we can pinpoint various commands
self.__p = self._w + "_orig"
self.tk.call("rename", self._w, self.__p)
self.tk.createcommand(self._w, self.__proxy)
#PROXY
def __proxy(self, cmd, *args) -> Any:
#boxselect and dragging only allow the BOXSELECT and ACTIVELINE tags from the moment the mouse is pressed
if ((self.__as and self.__as_free) or self.__selgrab) and (cmd=='tag') and args:
if args[0] in ('add', 'remove'):
if not args[1] in ('BOXSELECT','ACTIVELINE'):
return
#the rest of the time
try : target = self.tk.call((self.__p, cmd) + args)#;print(cmd, args)
except Exception: return
return target
def set_activeline(self):
r,_ = map(int, self.caret.split('.'))
self.tag_move('ACTIVELINE', f'{r}.0', f'{r+1}.0')
self.tag_lower('ACTIVELINE')
#FONT
def update_font(self, font:Iterable) -> None:
self.__font = tkf.Font(font=font)
self.__fw = self.__font.measure(' ')
self.__fh = self.__font.metrics('linespace')
self.config(font=font, tabs=self.__fw*4)
#create faux-carets for this font height
self.__loadcarets()
#FAUX-CARET
def __loadcarets(self) -> None:
#store insert on/off times for faux-caret `.after` calls
self.__instime = (self['insertofftime'], self['insertontime'])
fh = self.__fh #font height from 'linespace'
#make a temp xbm file
with tempfile.NamedTemporaryFile(mode='w+b', suffix='.xbm', delete=False) as f:
#create prettyprint xbm data
xbmdata = ',\n\t'.join(','.join('0xFF' for _ in range(min(8, fh-(8*i)))) for i in range(math.ceil(fh/8)))
#write xbm
f.write((f"#define image_width {INSWIDTH}\n#define image_height {fh}\n"
"static unsigned char image_bits[] = {\n\t"
f'{xbmdata}}};').encode())
#load xbm files for faux-caret ~ they have to be in this order
#this doesn't have a proper name because __fauxcaret manages this entirely, and it's existance should otherwise be ignored
self.__ = (tk.BitmapImage(file=f.name, foreground=SDW_CT), #shadow caret
tk.BitmapImage(file=f.name, foreground=self['background']), #off caret
tk.BitmapImage(file=f.name, foreground=self['insertbackground'])) #main caret
#delete file
os.unlink(f.name)
#faux-caret create or config
def __fauxcaret(self, index:str, on:bool=True, main:bool=False, cfg:bool=False) -> None:
(self.image_create, self.image_configure)[cfg](index, image=self.__[(main<<on)|(on^1)])
#blink the faux-caret(s)
def __blink(self, on:bool=True):
if not self.__boxselect: return #nothing to do
#so we only do this once per first blink
if not self.__blinksort:
self.__blinksort = sorted((self.index(n) for n in self.image_names()), key=lambda i: float(i))
if idx:=self.__blinksort:
if bnd:=self.__lbounds:
on = not on
#reconfigure all carets
for i in idx: self.__fauxcaret(i, on=on, cfg=True)
#reconfigure "active line" caret, if off it will assign off again
i = self.__index(idx[0], idx[-1], bnd.dn, bnd.rt)
self.__fauxcaret(i, on=on, main=True, cfg=True)
#schedule next call
self.__blinkid = self.after(self.__instime[on], self.__blink, on)
return
#raise ValueError('__blink: Nothing to sort!')
#reset blink data
def __blinkreset(self) -> None:
try : self.after_cancel(self.__blinkid) if not (self.__blinkid is None) else None
except Exception: pass
self.__blinkid = None
self.__blinksort = None
self['cursor'] = 'xterm'
#ALT+SHIFT
def __as_reset(self) -> None:
self.__as = False #unsuppresses all tags in .__proxy
self.__as_free = True #allows ALT+SHIFT hotkey
self.__as_commit = False
self.__as_arrow = False
self.__as_mouse = False
def __as_release(self, state:int=0) -> None:
self.__as_reset()
self.__as_free = not self.__boxselect #adjust
self.tag_replace('BOXSELECT', tk.SEL)
self.__blink()
#BOXSELECT
#reset box-select data
def __boxreset(self) -> None:
self['insertwidth'] = INSWIDTH #reset caret display width
self.tag_move(tk.SEL) #delete tk.SEL tags
self.__as_reset()
self.__boxclean()
self.__blinkreset()
self.__boxselect = False
self.__boxstart = None
self.__boxend = None
self.__lbounds = None
#remove any whitespace that box-select created
def __boxclean(self, bnd:SelectBounds=None) -> None:
if bnd:=(bnd or self.__lbounds):
self.__blinkreset()
for n in self.image_names(): self.delete(n) #delete faux-carets
p = self.caret
for nr in range(bnd.br, bnd.er+1):
b, e = f'{nr}.0', f'{nr}.end'
t = self.get(b, e) #get entire row
#if the entire row is just whitespace, get rid of the whitespace
if not len(ILWHITE.sub('', t)): self.replace_text(b, e, '')
else:
#strip only to the right of the column
self.replace_text(f'{nr}.{bnd.ec}', e, t[bnd.ec:].rstrip())
self.caret = p
#the end of the entire text is the only place where box-select will create new lines
r,_ = map(int, self.index('end-1c').split('.'))
#if we are on the last row
if nr == r:
#delete the last row until either it isn't blank or `nr` is exhausted
while not (l:=len(self.get(f'{nr}.0', 'end-1c'))) and nr>=bnd.br:
self.delete(f'{nr-1}.end', 'end-1c')
nr-=1
self.caret = p
#update __lbounds (w)ith (g)rab (o)ffsets
def __boxmove(self, wgo:bool=True) -> SelectBounds:
if b:=self.__lbounds:
r, c = map(int, self.caret.split('.'))
#update bounds
if self.__boxselect:
r = r if not wgo else max(1, r+self.__vgrabofs)
self.__lbounds = self.__bounds(f'{r}.{c}', f'{r+b.h}.{c+b.w}', ow=True, dn=b.dn, rt=b.rt)
#normal select
else:
self.__lbounds = self.__bounds(f'{r}.{c}', self.index(f'{r}.{c}+{b.w}c'), dn=b.dn, rt=b.rt)
return self.__lbounds
#CLIPBOARD
#restore clipboard data to last clipboard
def __restore_clipboard(self) -> None:
if self.__lclipbd:
self.clipboard_clear()
self.clipboard_append(self.__lclipbd)
#remove every selection-related thing
def __cut(self, p:str=None) -> bool:
#get selection ranges
r = self.tag_ranges(tk.SEL)
for i in range(0, len(r), 2):
self.tag_remove(tk.SEL, *r[i:i+2]) #remove tk.SEL tag
self.replace_text(*r[i:i+2], '') #delete selected text
self.caret = p or self.caret #put the caret somewhere
return bool(r)
#move all selected text to clipboard
#this is safe for regular selected text but designed for box-selected text
def __copy(self) -> bool:
r = self.tag_ranges(tk.SEL)
#compile clipboard data from ranges
t = '\n'.join(self.get(*r[i:i+2]) for i in range(0,len(r),2))
if t:
#bkup, clear and populate clipboard
#bkup used with drag to restore the clipboard after drop
try : self.__lclipbd = self.clipboard_get()
except tk.TclError: self.__lclipbd = ''
self.clipboard_clear()
self.clipboard_append(t)
return bool(r)
#insert clipboard text
def __paste(self) -> bool:
try:
l = self.clipboard_get().split('\n') #get lines
except tk.TclError: return False
r,c = map(int, self.caret.split('.')) #get caret row, col
#we can't go by columns due to tabs being possibly included
x,_,_,_ = self.bbox(self.caret)
#true single space character count from beginning of line to caret
pc = int(x//self.__fw)
#insert each line at an incrementing row index
for i,t in enumerate(l):
p = f'{r+i}.{c}'
#if the row doesn't exist, create it
if self.compare(p, '>=', 'end'): self.insert(tk.END, f'\n')
#if the column doesn't exist, create it
q = f'{r+i}.end'
if self.compare(p, '>=', q) and i:
#what's the difference in available and desired columns
n = pc-int(self.index(q).split('.')[-1])
#add enough space to keep us in line, and add the text while we are at it
self.insert(q, f'{" "*(n)}{t}')
continue
#insert (t)ext at (p)osition
self.insert(p, t)
self.caret = f'{r}.{c}' #put the caret at the top-left of the paste
return True
#EVENTS
def __handler(self, event) -> None:
if event.type == tk.EventType.KeyPress:
#Shift and Alt facts
shift = (event.keysym in SHIFTS) or (event.state & SHIFT)
alt = (event.keysym in ALTS ) or (event.state & ALT )
shiftonly = shift and not alt
altonly = alt and not shift
self.__as = alt and shift
if event.state & CONTROL:
if event.keysym=='c':
#if not boxcopy, normal cut/copy/paste behaviors are used
self.__boxcopy = not self.__as_commit and self.__boxselect
#BOXSELECT COPY(Cntl+c)
if self.__boxcopy:
self.__copy()
return 'break'
elif event.keysym=='x':
#if not boxcopy, normal cut/copy/paste behaviors are used
self.__boxcopy = not self.__as_commit and self.__boxselect
#BOXSELECT CUT(Cntl+x)
if self.__boxcopy:
self.__copy()
self.__cut()
self.__boxreset()
return 'break'
elif event.keysym=='v':
#BOXSELECT PASTE(Cntl+v)
if self.__boxcopy:
self.__cut() #if any
#set caret to begin position
if b:=self.__lbounds: self.caret = f'{b.br}.{b.bc}'
self.__paste()
self.__boxreset()
return 'break'
#Shift+Arrow while in box-select mode moves entire selection in Arrow direction
elif event.keysym in ARROWS and shiftonly and self.__boxselect:
if not (bnd:=self.__lbounds): return
self.__blinkreset()
#multiline-caret
mc = self.__bounds(f'{bnd.br}.{bnd.bc}', f'{bnd.er}.{bnd.bc}', ow=False)
#track any effect a deletion has on our drop index
self.mark_set(INSPNT, f'{bnd.br}.{bnd.bc}')
self.__copy()
self.__cut(INSPNT)
self.__boxclean(mc)
self.__aindex(event.keysym) #move caret according to keysym
self.__paste()
self.__restore_clipboard()
bnd = self.__boxmove(False)
for _,_,_ in self.__bounds_range(tk.SEL, eo=not bnd.rt, ao=True): pass
self.__blink()
return 'break'
elif event.keysym=='BackSpace':
#BOXSELECT BackSpace
if self.__boxselect:
for r,bc,ec,adv in self.__typing_range(-1):
if adv<0: self.delete(f'{r}.{bc-1}', f'{r}.{ec}')
return 'break'
return
#deselects and moves caret to the start(left) or end(right) of the former selection
#works for any type of selection
elif (event.keysym in HARROWS) and (not self.__as):
if b:= self.__bounds(*self.tag_bounds(tk.SEL)):
self.__boxreset()
self.caret = f'{b.er}.{b.ec}' if 'Right' in event.keysym else f'{b.br}.{b.bc}'
return 'break'
return
#BOXSELECT
if self.__as:
#if this keysym isn't Shift, Alt or an Arrow, ignore and act like it never happened
ar, ks = (event.keysym in ARROWS), (event.keysym in ALTSHIFTS)
if (ar and not (event.state&ARROWKEY)) or not (ar or ks):
return 'break'
#capture selection method
self.__as_mouse = (self.__as_mouse or bool(event.state&BUTTON1)) and (not self.__as_arrow)
self.__as_arrow = (self.__as_arrow or (event.keysym in ARROWS )) and (not self.__as_mouse)
if (self.__as_mouse or self.__as_arrow) and self.__as_free:
#box-select mousedown
if not self.__as_commit:
self.__boxselect = True
self.__as_commit = True
#store last known 'insert' index
self.__boxstart = self.__linsert
if self.__as_mouse: return 'break'
#if we committed to arrow-selecting, and we aren't pressing an arrow, return
if self.__as_arrow and (event.keysym in ALTSHIFTS): return 'break'
#box-select mousemove ~ via last keypress (shift, alt, arrow) constantly firing
#vindex might not exist yet.
self.__boxend = (self.__aindex(event.keysym), self.__vindex(event.x, event.y))[self.__as_mouse]
#never use overwrite here, if you do you will lose the proper selection direction
if (bnd:=self.__bounds(ow=False)) == self.__lbounds: return
#remove whitespace and carets from last bounds, overwrite last bounds
self.__boxclean()
self.__lbounds = bnd
#draw rect
for r, bc, ec in self.__bounds_range('BOXSELECT', bo=not bnd.rt, eo=1):
_, lc = map(int, self.index(f'{r}.end').split('.'))
self.insert(f'{r}.{ec}', ' '*max(0, ec-lc)) #add columns if necessary
return 'break'
#box-select mouseup ~ deinit hotbox
if self.__as_commit and self.__as_mouse:
self.__as_release(event.state)
return 'break'
#store 'insert' position before BUTTON1 press
self.__linsert = self.caret
return 'break'
else:
#BOX-TYPING
if self.__boxselect and len(event.char):
#typing_range handles the faux-caret and bounds, we just need to insert
for r,c,_,_ in self.__typing_range(1): self.insert(f'{r}.{c}', event.char)
return 'break'
elif event.type == tk.EventType.KeyRelease:
if self.__as_commit and (event.keysym in ALTSHIFTS):
self.__as_release(event.state)
return 'break'
#this catches pressing and releasing hotbox without ever clicking the mouse
self.__as = False
elif event.type == tk.EventType.ButtonPress:
mse = self.index('current') #get mouse index
self.set_activeline()
#GRAB SELECTED
#check if mouse index is within a selection
if tk.SEL in self.tag_names(mse):
#box-selection
if b:=self.__lbounds:
r,_ = map(int, mse.split('.')) #get mouse index row
self.__vgrabofs = b.br-r #store vertical grab offset
#normal selection
else:
#create bounds for the selection ~ overwrite boxstart/boxend with min/max indexes
self.__lbounds = self.__bounds(*self.tag_bounds(tk.SEL), ow=True)
self.tag_replace(tk.SEL, 'BOXSELECT')
self.__selgrab = True #only allow BOXSELECT tag in .__proxy
self.__linsert = mse #store the grab position
self.__blinkreset()
#if a selection is not under the mouse, reset
elif self.__boxselect: self.__boxreset()
elif event.type == tk.EventType.Motion:
if event.state&BUTTON1: self.set_activeline()
if not self.__selgrab: return
#if you are where you started, then dragging is false
self.__seldrag = self.caret != self.__linsert
#cursor indicates state
self['cursor'] = ('xterm', 'fleur')[self.__seldrag]
self['insertwidth'] = INSWIDTH
elif event.type == tk.EventType.ButtonRelease:
self.set_activeline()
if not self.__selgrab: return
#GRABBED
#turn on all tag add/remove in __proxy
self.__selgrab = False
self['cursor'] = 'xterm'
#nothing to drop ~ abort
if not self.__seldrag:
self.tag_move('BOXSELECT')
self.__boxreset()
return
self.__seldrag = False
self.tag_replace('BOXSELECT', tk.SEL)
#make a "multiline caret" so __boxclean works down every row...
#in what will be the only remaining column, after deletion
mc = None
if self.__boxselect:
mc = self.__bounds(*self.tag_bounds(tk.SEL), ow=False)
mc = self.__bounds(f'{mc.br}.{mc.bc}', f'{mc.er}.{mc.bc}', ow=False)
#DROP SELECTED
if bnd:=self.__boxmove(): # move bounds to current location
#COPY
self.__copy()
#CUT
#this tracks any effect a deletion has on where we are trying to drop this
self.mark_set(INSPNT, (self.caret, f'{bnd.br}.{bnd.bc}')[self.__boxselect])
self.__cut(INSPNT) #delete selection and move caret to insertion point
#PASTE NORMAL
if not self.__boxselect:
ip = self.caret
self.event_generate('<<Paste>>')
self.__lbounds = self.__bounds(ip, self.caret, ow=True)
self.set_activeline()
self.tag_move(tk.SEL, ip, self.caret) #clear and draw tk.SEL
return
self.__boxclean(mc)
#PASTE COLUMN
self.__paste()
self.__restore_clipboard()
bnd = self.__boxmove(False) #move bounds to caret
#draw rect
for _,_,_ in self.__bounds_range(tk.SEL, eo=not bnd.rt, ao=True): pass
self.__blink()
#example
if __name__ == '__main__':
ROWS = 20
class App(tk.Tk):
def __init__(self, *args, **kwargs):
tk.Tk.__init__(self, *args, **kwargs)
self.columnconfigure(0, weight=1)
self.rowconfigure (0, weight=1)
#instantiate editor
(bst := BoxSelectText(self)).grid(sticky='nswe')
#columnar text
cols = ''.join(f'| {chr(o)*3} |' for o in range(97,123))
full = '\n'.join(cols for _ in range(ROWS))
#create a playground to test column select features
bst.text = f'{full}\n\n\n{full}'
#run
App().mainloop()