简体   繁体   中英

tkinter Text Widget: how to indent automatically after a soft line wrap

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:

  • 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.

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.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM