When entering a hyphen at line start, I want the Text widget treat the following text as section where subsequent lines are indented, like so:
Instead the new line starts at the left border without indentation.
Among the attributes of the Text widget I found spacing1, spacing2, spacing3 all referring to vertical line space. No hint for horizontal spacing. Am I missing the appropriate attribute or do I have to code the desired behaviour from scratch?
If so, I would have to bind to the line wrap event, but I think there is none.
Does anyone have a clue how to solve that?
You can add the attributes lmargin1
and lmargin2
to a tag, and then apply that tag to a range of text to control the left margin.
Here's an example (you'll have to adjust the part that queries the font if you use a custom font that isn't a named font):
import tkinter as tk
from tkinter import font
root = tk.Tk()
text = tk.Text(root, wrap="word", width=40, height=10)
text.pack(fill="both", expand=True)
text_font = font.nametofont(text.cget("font"))
bullet_width = text_font.measure("- ")
em = text_font.measure("m")
text.tag_configure("bulleted", lmargin1=em, lmargin2=em+bullet_width)
message = (
"by entering this hyphen (I really mean hyphen, not that bullet "
"that is automatically generated) the section to be indented starts. "
"When reaching the right border, a soft wrap is performed (I'm using "
"wrap=tk.WORD) and the new line should look like it does here."
)
text.insert("end", "- " + message, "bulleted")
root.mainloop()
My version is intended to work while you type, allow you to pass text to be formatted, as-well-as open and save bullet formatted files. The meat and potatoes of the code is commented.
main.py
from configure import *
#used for non-bulleted lines with indention
Sub = chr(8204) #completely invisible but exists
class BulletMenu(ttk.Frame):
@property
def info(self) -> str:
return self.info_lbl['text']
@info.setter
def info(self, text:str):
self.info_lbl['text'] = text
def timestamp(self, ts:bool=False):
self.time = self.time if not ts else ts
if self.time:
self.info = strftime("%I:%M %a %b %d %Y")
self.after(10000, self.timestamp)
def __init__(self, master, row:int=0, column:int=0, **kwargs):
ttk.Frame.__init__(self, master, style='custom.TFrame', **kwargs)
self.grid(row=row, column=column, sticky='nswe', ipady=4)
self.grid_rowconfigure(0, weight=1)
self.grid_columnconfigure(0, weight=1)
#info label
self.info_lbl = ttk.Label(self, style='custom.TLabel', anchor='center')
self.info_lbl.grid(row=0, column=0, sticky='ew')
self.time = True
self.timestamp()
#feature separator
ttk.Separator(self, orient='vertical', style='custom.TSeparator').grid(row=0, column=1, sticky='ns', padx=(4,4))
#filename entry label
ttk.Label(self, text='save as:', style='custom.TLabel').grid(row=0, column=2, sticky='e')
#filename entry
fn_ent = tk.Entry(self, width=32, font='Helvetica 10 bold', **asdict(Entry_dc()))
fn_ent.grid(row=0, column=3, sticky='e', padx=(4,6))
#save button
ttk.Button(self, text="save", style='custom.TButton', command=lambda: master.save(fn_ent.get())).grid(row=0, column=4, sticky='e', padx=(2,2))
#feature separator
ttk.Separator(self, orient='vertical', style='custom.TSeparator').grid(row=0, column=5, sticky='ns', padx=(2,2))
#open button
ttk.Button(self, text="open", style='custom.TButton', command=master.open).grid(row=0, column=6, sticky='e', padx=(2,4))
def displayInfoFor(self, info:str, millis:int=5000):
self.time = False
self.info = info
self.after(millis, lambda: self.timestamp(True))
class BulletEditor(ttk.Frame):
@property
def bytes(self) -> bytes:
return self.tf.get('1.0', 'end-1c').encode('utf-8')
@property
def text(self) -> str:
return self.tf.get('1.0', 'end-1c')
@text.setter
def text(self, text:str):
#delete all text
self.tf.delete('1.0', 'end')
#create a list of lines
lines = text.split('\n')
for ln, line in enumerate(lines):
#insert the new line
self.tf.insert(f'{ln+1}.0', f'{line}\n')
#apply formatting accordingly
if line[0:1] in self.bullet_chr: ##bullet
self.tf.tag_remove("child", f'{ln+1}.0-1c', 'end-1c')
self.tf.tag_add('bullet', f'{ln+1}.0', f'{ln+1}.1')
self.tf.tag_add('parent', f'{ln+1}.1', 'end-1c')
elif line[0:1] == Sub: ##sub
self.tf.tag_add('child', f'{ln+1}.0-1c', 'end')
else: ##normal
self.tf.tag_remove("child", f'{ln+1}.0-1c', 'end')
self.tf.tag_remove("parent", f'{ln+1}.0-1c', 'end')
self.tf.tag_remove("bullet", f'{ln+1}.0-1c', 'end')
def __init__(self, master, row:int=0, column:int=0, **kwargs):
ttk.Frame.__init__(self, master, style='custom.TFrame', **kwargs)
self.grid(row=row, column=column, sticky='nswe')
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(0, weight=1)
#textfield
self.tf = tk.Text(self, **asdict(Text_dc()))
self.tf.grid(row=0, column=0, sticky='nswe')
#vertical scrollbar
self.vs = ttk.Scrollbar(self, style='arrowless.Vertical.TScrollbar', command=self.tf.yview)
self.vs.grid(row=0, column=1, sticky='nswe')
#attach scrollbar to textfield
self.tf.configure(yscrollcommand=self.vs.set)
#font metric
fontname = self.tf.cget("font").split()[0]
fontsize = self.tf.cget("font").split()[1]
child = font.Font(self, fontname).measure("- ")
#tags
self.tf.tag_configure("parent", lmargin2=child, foreground=Theme.Gloss)
self.tf.tag_configure("bullet", foreground=Theme.Trim, font=f'{fontname} {fontsize} bold')
self.tf.tag_configure("child", lmargin1=child, lmargin2=child, foreground=Theme.Gloss)
#bullets
self.bullet_chr = ['-', '.', '*', '+']
self.bullet_sym = ['minus', 'period', 'asterisk', 'plus']
#events
for b in self.bullet_sym:
self.tf.bind(f'<KeyPress-{b}>', self.applyformat)
self.tf.bind('<KeyPress-Return>', self.applyformat)
self.tf.bind('<Control-Return>', self.applyformat)
def applyformat(self, event:tk.Event):
#current position - 1 (this is before insertion)
position = self.tf.index('insert')
#convert line and character to int
l = int(position.split('.')[0])
c = int(position.split('.')[1])
#apply formatting accordingly
if event.keysym in self.bullet_sym and c == 0: ##bullet
self.tf.tag_remove("child", f'{l}.0-1c', 'end-1c')
self.tf.insert(position, f'{event.char} ')
self.tf.tag_add('bullet', f'{l}.0', f'{l}.1')
self.tf.tag_add('parent', f'{l}.1', 'end-1c')
return 'break'
elif event.keysym == 'Return': ##sub
if event.state & 0x4:
self.tf.insert(position, f'\n{Sub}')
self.tf.tag_add('child', f'{l+1}.0-1c', 'end')
else: ##normal
self.tf.insert(position, f'\n')
self.tf.tag_remove('parent', f'{l+1}.0-1c', 'end')
self.tf.tag_remove("child", f'{l+1}.0-1c', 'end')
self.tf.tag_remove("bullet", f'{l+1}.0-1c', 'end')
self.tf.see(self.tf.index('insert+1l'))
return 'break'
#chain of effects to apply to window close
EffectChain = (Effect_e.Alpha, Effect_e.Width, Effect_e.Height, Effect_e.X, Effect_e.Y)
class App(tk.Tk):
DIR:str = path.join(getcwd(), 'bullets/')
def __init__(self):
tk.Tk.__init__(self)
#init custom theme
CustomTheme()
#simple window effects
Window.ApplyEffects(self, 10, Effect_e.Alpha, ([1],), 20)
Window.EnableCloseEffect(self, 10, EffectChain)
#make editor dominant in size
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(2, weight=1)
#create directory to store bullets, if it doesn't exist
if not path.isdir(App.DIR):
mkdir(App.DIR)
#instantiate menu
self.menu = BulletMenu(self)
#feature separator
ttk.Separator(self, orient='horizontal', style='stack.TSeparator').grid(row=1, column=0, sticky='nswe', ipady=1)
#instantiate editor
self.editor = BulletEditor(self, 2, 0)
#example of assigning text directly
self.editor.text = (
"* Bullets can be created with '*', '.', '-', and '+'. "
"Bullets will soft wrap to the next line when enough text is supplied."
f"\n{Sub}\n{Sub}Control+Return creates a bulletless, indented and soft-wrapped line. "
"You can use this when you want to add paragraphs under your bullet.\n\n"
"Pressing just Return, breaks out of indentation and hard wraps text. "
"The font color changes, as well.\n\n"
"* You can open and save files. Opening a .bullet file will display "
"formatting identical to what you saw in the editor when you saved. "
".bullet files are automatically saved to AppDirectory/bullet/."
"When you invoke opening of a .bullet file you are automatically brought to AppDirectory/bullet/.\n\n"
"* You do not have to include the file extension in the \"save as:\" Entry field. "
"It is appended automatically when you click save.\n\n"
"Enjoy."
)
def save(self, filename:str):
if filename:
filename = filename.replace('.bullet', '')
with open(f'{App.DIR}{filename}.bullet', 'wb') as f:
f.write(self.editor.bytes)
self.menu.displayInfoFor(f'saved: {filename}.bullet', 2000)
else:
self.menu.displayInfoFor(f'supply a filename', 2000)
def open(self):
bullet:str = filedialog.askopenfilename(initialdir=App.DIR, title="Select Bullet", filetypes=(("bullet files","*.bullet"),("all files", "*.*")))
if bullet:
with open(bullet, 'r') as f:
self.editor.text = re.compile('‌').sub(Sub, f.read())
if "__main__" == __name__:
app = App()
app.configure(**asdict(WindowStyle_dc()))
app.title("Bullet Editor v0.28")
app.geometry('800x600+100+100')
app.attributes('-alpha', 0.0)
app.minsize(640, 480)
app.mainloop()
configure.py
import re
import tkinter as tk
import tkinter.ttk as ttk
from tkinter import font
from tkinter import filedialog
from time import strftime
from os import mkdir, path, getcwd
from dataclasses import dataclass, asdict
from collections import namedtuple
from typing import Tuple, Callable, Union, List, Iterable
from enum import Enum
#declare theme colors
Theme_t = namedtuple('Theme_t', 'Base Primer Topcoat SubContrast Contrast Trim Hilight Accent Flat Gloss')
STACKTHEME = Theme_t('#2d2d2d', '#697075', '#3d3d3d', '#646464', '#f48024', '#71dbfe', '#dad9c6', '#AAAAAA', '#dad9c6', '#e7929a')
#YOURTHEME = Theme_t('#', '#', '#', '#', '#', '#', '#', '#', '#', '#')
Theme = STACKTHEME
''' THEME FONTS '''
FONT = False
FONT1 = 'Fixedsys'
FONT2 = 'Calibri'
FONT3 = 'Helvetica'
FONT4 = 'Consolas'
@dataclass
class Entry_dc:
foreground: str = Theme.Flat
background: str = Theme.Topcoat
borderwidth: str = 2
highlightbackground: str = Theme.Primer
highlightcolor: str = Theme.Primer
insertbackground: str = Theme.Primer
selectforeground: str = Theme.Base
selectbackground: str = Theme.Flat
relief: str = 'flat'
highlightthickness: int = 2
selectborderwidth: int = 0
insertborderwidth: int = 2
insertwidth: int = 2
@dataclass
class Label_dc:
foreground: str = Theme.Contrast
background: str = Theme.Base
font: Tuple = (FONT3, 10, 'bold')
@dataclass
class Button_dc:
foreground: str = Theme.Contrast
background: str = Theme.Base
bordercolor: str = Theme.Base
darkcolor: str = Theme.Base
lightcolor: str = Theme.Base
highlightcolor: str = Theme.Base
relief: str = 'flat'
compound: str = 'left'
highlightthickness: int = 0
shiftrelief: int = 0
width: int = 0
padding = [2,2,2,2] #l,t,r,b
font: Tuple = (FONT3, 10)
anchor: str = 'nw'
@dataclass
class Scrollbar_dc:
background: str = Theme.SubContrast
bordercolor: str = Theme.Topcoat
darkcolor: str = Theme.SubContrast
lightcolor: str = Theme.SubContrast
troughcolor: str = Theme.Topcoat
arrowcolor: str = Theme.Trim
gripcount: int = 0
arrowsize: int = 14
@dataclass
class Separator_dc:
background: str = Theme.Primer
@dataclass
class Text_dc:
background: str = Theme.Topcoat # main element
foreground: str = Theme.Flat
borderwidth: int = 0
selectbackground: str = Theme.Flat # selected text
selectforeground: str = Theme.Base
selectborderwidth: int = 0 # doesn't seem to do anything ~ supposed to be a border on selected text
insertbackground: str = Theme.Hilight # caret
insertborderwidth: int = 0 # border
insertofftime: int = 300 # blink off millis
insertontime: int = 600 # blink on millis
insertwidth: int = 2 # width
highlightbackground:int = Theme.Topcoat # inner border that is activated when the widget gets focus
highlightcolor: int = Theme.Topcoat # color
highlightthickness: int = 0 # thickness
cursor: str = 'xterm'
exportselection: int = 1
font: str = f'{FONT4} 14'
width: int = 16 # characters ~ often this is ignored as Text gets stretched to fill its parent
height: int = 8 # lines ~ " " " " "
padx: int = 8
pady: int = 8
relief: str = 'flat'
wrap: str = 'word' # "none", 'word'
spacing1: int = 0 # space above every line
spacing2: int = 0 # space between every wrapped line
spacing3: int = 0 # space after return from wrap (paragraph)
state: str = 'normal' # NORMAL=Read/Write | DISABLED=ReadOnly
tabs: str = '2.5c'
takefocus: int = 1
undo: bool = True
xscrollcommand: Callable = None
yscrollcommand: Callable = None
@dataclass
class Frame_dc:
background: str = Theme.Base
bordercolor: str = Theme.Base
darkcolor: str = Theme.Base
lightcolor: str = Theme.Base
relief: str = 'raised'
class CustomTheme(ttk.Style):
def __init__(self, basetheme='clam'):
ttk.Style.__init__(self)
#create theme
self.theme_create('custom', basetheme, {
'custom.TSeparator': {
'layout': [],
'configure': asdict(Separator_dc()),
},
'stack.TSeparator': {
'layout': [],
'configure': asdict(Separator_dc(background=Theme.Contrast)),
},
'arrowless.Vertical.TScrollbar': {
'layout': [('Vertical.Scrollbar.trough',
{'sticky': 'ns', 'children': [
('Vertical.Scrollbar.thumb', {'expand': '1', 'sticky': 'nswe'})
]}
)],
'configure': asdict(Scrollbar_dc()), # arrowsize will still size the scrollbar thickness
},
'custom.TButton': {
'configure': asdict(Button_dc()),
'map': {
'background': [('active', Theme.Contrast),('pressed', Theme.Contrast)],
'foreground': [('active', Theme.Base), ('pressed', Theme.Base)],
}
},
'custom.TLabel': {
'configure': asdict(Label_dc()),
},
'custom.TFrame': {
'configure': asdict(Frame_dc()),
},
'custom.TEntry': {
'configure': asdict(Entry_dc()),
},
})
self.theme_use('custom')
#property to add effect to
class Effect_e(Enum):
Width = 0
Height = 1
X = 2
Y = 3
Alpha = 4
#effect type-hint alias
Effects = Union[Effect_e, Iterable[Effect_e]]
@dataclass
class WindowStyle_dc:
bd: int = 0
relief: str = 'flat'
bg: str = 'black'
highlightbackground: str = 'black'
highlightcolor: str = 'black'
highlightthickness: int = 0
padx: int = 0
pady: int = 0
@dataclass
class WindowAttr_dc:
alpha: float = 0
disabled: bool = False
fullscreen: bool = False
toolwindow: bool = False #windows
topmost: bool = True #windows
transparentcolor: str = '#000001'
#modified: bool = False #mac
#titlepath: str = '' #mac
#used to parse root.geometry()
Geometry_re = re.compile('^(\d+)x(\d+)\+(\d+)\+(\d+)$')
#window effects manager
class Window:
@staticmethod
def GetGeometryList(window):
return list(map(int, Geometry_re.search(window.geometry()).groups()))
@staticmethod
def ListSetGeometry(window, geom:List[int]):
sep = ['', 'x', '+', '+']
geo = ''
for g, s in zip(geom, sep):
geo = f'{geo}{s}{g}'
window.geometry(geo)
@staticmethod
def EnableCloseEffect(window, steps:int, effects:Effects, ranges:Iterable[List]=None, millis:int=20):
def close(window, steps, effects, ranges, millis):
geom = Window.GetGeometryList(window)
effects = effects if isinstance(effects, Iterable) else [effects]
if not ranges:
ranges =[]
for e in effects:
if e is Effect_e.Alpha:
ranges.append((1,0))
elif (e.value) < 4:
ranges.append((geom[e.value], 0))
window.minsize(0,0)
Window.ApplyEffects(window, steps, effects, ranges, millis, True)
window.protocol("WM_DELETE_WINDOW", lambda: close(window, steps, effects, ranges, millis))
@staticmethod
def ApplyEffects(window, steps:int, effects:Effects, ranges:Iterable[List], millis:int=20, destroy:bool=False, minsize:Iterable=None, step=0, geom:List[int]=None):
step += 1
effects = effects if isinstance(effects, Iterable) else [effects]
for e, r in zip(effects, ranges):
r.insert(0, 0) if len(r) == 1 else None
amt = (r[1]-r[0])/steps
if e is Effect_e.Alpha:
alpha = (float(window.attributes('-alpha')) + amt) if steps > step else r[1]
window.attributes('-alpha', alpha)
elif (e.value) < 4:
geom = Window.GetGeometryList(window) if not geom else geom
geom[e.value] = int(geom[e.value] + amt) if steps > step else r[1]
if geom:
Window.ListSetGeometry(window, geom)
if step < steps:
window.after(millis, lambda: Window.ApplyEffects(window, steps, effects, ranges, millis, destroy, minsize, step, geom))
else:
if minsize:
window.minsize(*minsize)
if destroy:
window.destroy()
The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.