简体   繁体   English

tkinter 文本小部件:如何在软换行后自动缩进

[英]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:在行开头输入连字符时,我希望 Text 小部件将以下文本视为后续行缩进的部分,如下所示:

  • 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.当到达右边界时,将执行软换行(我使用的是 wrap=tk.WORD)并且新行应该看起来像这里一样。

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.在 Text 小部件的属性中,我发现 spacing1、spacing2、spacing3 都是指垂直行距。 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.您可以将属性lmargin1lmargin2添加到标签,然后将该标签应用于文本范围以控制左边距。

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()

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

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