Αντρω δι Νεξυνω

[Tkinter] una Text-box con "buffering"

« Older   Newer »
  Share  
view post Posted on 26/2/2024, 18:14
Avatar

Nubbio x Sempre

Group:
Moderazione globale
Posts:
7,226

Status:


Un altro dei miei sempre più rari post, anche questa volta per rispondere ad un collega che chiedeva consiglio circa la sostituzione di una label con un "qualcosa" fornito di sbarra di scorrimento e con buffer di righe limitato.
Andando detto oggetto sostitutivo a rimpiazzare una label, naturalmente, a sua volta non doveva essere editabile.

Sfruttando la classe tkinter.Text stato facile realizzare un sub-classamento idoneo allo scopo, qui di seguito il codice del modulo realizzato ("mytktools.py")

CODICE
# -*- coding: utf-8 -*-

import tkinter as tk

class BufferedText(tk.Text):
   '''Una Text-box inibibile alla scrittura senza disabilitazione
      e che espone un numero stabilito di righe di testo.'''
   key_none = ['Alt_L', 'Alt_R', 'Caps_Lock', 'Control_L', 'Control_R', 'Down', 'End',
               'Escape', 'Execute', 'F1', 'F2', 'Fi', 'F12', 'Home', 'Insert', 'Left',
               'Linefeed', 'KP_Add', 'KP_Begin', 'KP_Divide', 'KP_Down', 'KP_End',
               'KP_Home', 'KP_Insert', 'KP_Left', 'KP_Next', 'KP_Prior','KP_Right',
               'KP_Up', 'Next', 'Num_Lock', 'Pause', 'Print', 'Prior', 'Right',
               'Scroll_Lock', 'Shift_L', 'Shift_R', 'Tab', 'Up']
   def __init__(self, parent: callable, active: bool=False, rowsize: int=0, *args, **kwargs) -> None:
       super().__init__(parent, *args, **kwargs)
       self.parent = parent
       self._active = active
       self._rowsize = rowsize if rowsize >=0 else 0
       self._bg = self['background']
       self.bind('<KeyPress>', self._on_key)
       self.bind('<FocusIn>', self._on_focus)
       self.bind('<FocusOut>', self._out_focus)

   def _on_key(self, evt: callable) -> None:
       if  evt.keysym in ['Return', 'KP_Enter'] and self._rowsize:  # valuta il caso si aggiunga una riga
           if not self._active: return 'break'
           self._evaluate_rows()
       elif not self._active and not evt.keysym in self.key_none:
           return 'break'
   
   def _evaluate_rows(self):
       if not self._rowsize: return
       rows = int(self.index('end').split('.')[0]) - 2
       if self._active and self.parent.focus_get() == self:
           rows += 1
       if rows > self._rowsize:  # raggiunto il limite di righe stabilito
           for i in range(rows - self._rowsize):
               self.delete('1.0', '1.end + 1 char')
               self.update()
           
   def _on_focus(self, evt: callable) -> None:
       color = '#ffffc0' if self._active else '#bfe5f1'
       self.configure(bg=color)
   
   def _out_focus(self, evt: callable) -> None:
       self.configure(bg=self._bg)
   
   def is_active(self) -> bool:
       return self._active
   
   def activate(self) -> None:
       self._active = True
   
   def disable(self) -> None:
       self._active = False

   @property
   def buffer(self) -> int:
       return self._rowsize

   @buffer.setter
   def buffer(self, buff: int) -> None:
       self._rowsize = buff if buff >=0 else self._rowsize
       self._evaluate_rows()

   def add_text(self, text: str) -> None:
       self.insert('end', text)
       self._evaluate_rows()
       self.see('end')


Il sub-classamento realizzato (BufferedText) definisce una API con cui l'utilizzatore può il buffer di righe voluto, un metodo interno che provvederà a conteggiare le righe presenti nell'area di testo ed a eliminare le prime righe eccedenti.
Naturalmente, vengono conserveti i metodi e le proprietà proprie della classe madre, inoltre sono state aggiunte due ulteriori proprietà, non pertinenti il problema "righe" : "_active" e "_bg"

Caratteristiche proprie di BufferedText


Si tralasciano i metodi e le proprietà caratteristiche della classe madre tkinter.Text, per le quali si rimanda alla documentazione, fatta salva la proprietà "background" che con BufferedText non funzionerà, venendo sovrascrittà alla acquisizione o perdita del focus da parte di un oggetto di essa istanza.
Un oggetto di istanza "BufferedText" che non possiede il focus presenterà comunque il colore di base previsto per oggetti tkinter.Text nel sistema operativo in uso, quando avrà il focus, invece, assumerà un colore dipendente dallo stato delle proprietà "._active", che è un valore booleano, lo sfondo sarà di un giallino chiaro se "_active" è "Vero", un leggero azzurro-violaceo se "Falso". Dette colorazioni non possono essere modificate "ad voluntatem" essendo intese, nei miei utilizzi, quali "fisse" per i controlli cui applico la tecnica e le considerazioni che descriverò tra poco ... qualora si volessero cambiare le colorazioni, si intervenga sulla riga
CODICE
color = '#ffffc0' if self._active else '#bfe5f1'

contenuta nel metodo "_on_focus" della classe.
Qualora non si volesse tale comportamento si elimino i binding del focus ed i metodi "_on_focus" e "out_focus", oltre, naturalmente, la definizione "self._bg" che diverrebbe inutile.

Il perché di _active


Una delle caratteristiche di tkinter che trovo "molto sgradevole" è la resa grafica di un controllo con stato "disabled", in particolare nei controlli che espongono testo, in tutti i miei desktop linux detta resa è decisamente brutta (non è che in windows sia meglio) ed oltre tutto pochissimo legibile.
Pertanto, quando è possibile (solitamente per Entry e Text) e voglio che il contenuto non venga modificato definisco una proprietà, appunto "_active", che stabilisce la possibilità di modificare i contenuti del widget e procedo ad intercettare la tastiera nella fase iniziale della sua gestione in tkinter ("KeyPress") ed analizzare i singoli tasti premuti, qualora lo stato di "_active" sia "Falso" viene interrotto il processo (con un "return 'break'") per tutti quei tasti che causerebbero una modifica dei contenuti mentre si lasciano filtrare i tasti di navigazione o ininfluenti ... il codice relativo:

CODICE
def _on_key(self, evt: callable) -> None:
       if  evt.keysym in ['Return', 'KP_Enter'] and self._rowsize:  # valuta il caso si aggiunga una riga
           if not self._active: return 'break'
           self._evaluate_rows()
       elif not self._active and not evt.keysym in self.key_none:
           return 'break'

Tenete presente questo stralcio di codice, ci si ritornerà in seguito.

La Api della classe fornisce sue metodi per la definizione dello stato di "_active" : "activate()" che abilita il controllo alle modifiche del contenuto e "disable()" che le inibisce.

Definizione del buffer di righe


Dopo un po' di prove e considerazioni ho deciso di fornire la classe "BufferedText" di due distinte modalità per stabilire quante righe essa debba conservare per la visualizzazione.

La prima modalità è implementata nella inizializzazione dell'istanza alla classe
CODICE
def __init__(self, parent: callable, active: bool=False, rowsize: int=0, *args, **kwargs) -> None:

ed è definibile tramite il parametro "rowsize", se omesso tale parametro assumerà valore "0", con tale valore nessun limite di riga verrà imposto e BufferedText funzionerà come un normale tkinter.Text (a parte i colori), diversamente il valore fornito, se positivo, verrà assunto quale limite delle righe di testo da rappresentare.

la seconda modalità è tramite i metodi/proprietà "buffer" il cui getter (decoratore "property") permetterà di consultare l'oggetto istanziato per conoscere quante righe sono correntemente rappresentabili ed il cui setter ("buffer.setter") permetterà di ridefinirne il numero.
Qualora il numero di righe ridefinito fosse inferiore alle righe già presenti le eccedenze verrebbero immediatamente eliminate, senza richiesta di conferma.

Come operare


BufferedText è pensata essenzialmente per una esposizione passiva di testo proveniente dall'esterno, a tale scopo è stata dotata di un apposito metodo
CODICE
def add_text(self, text: str) -> None:

che provvederà ad inserire il testo così come passato continuando dall'ultima posizione presente, quindi invocherà il metodo "privato" di valutazione delle righe e posizionerà la visione del controllo sull'ultima riga

png


raggiunto il limite imposto, l'inserimento di una nuova riga di testo

png


causerà l'immediata eliminazione della prima riga di testo presente

png


come è evidente nelle soprastanti immagini della finestra di test (che sarà fornita alla fine del post).

L'importanza del new-line ("\n")


Si faccia ben attenzione che il tkinter.Text definisce una riga di testo quale testo terminato da un carattere di new-line ('\n' in python), ciò è significativo perché tkinter.Text è dotato anche della proprietà "wrap" che può avere diversi valori, se tale proprietà è "none" il testo verrà mostrato su di un'unica linea anche se eccede l'area orizzontale di visualizzazione

png


in tal caso un eventuale conteggio del numero di righe visualizzate dovrebbe corrispondere al numero imposto nel buffer ... più una se anche all'ultima riga trasmessa è stato apposto un new-line.
posto "wrap" ad un valore diverso, tipo "word" p.e., una singola riga di testo può occupare diverse linee di visualizzazione

png


il cui eventuale conteggio potrebbe eccedere anche di molto il numero di righe da visualizzare ... potete rendervi edotti visivamente della differenza osservando gli spazi di spostamento delle scroolbar nelle figure sopra, ove all'azzeramento dello spazio della scrollbar orizzontale nella soconda figura corrisponde una abbondande triplicazione dello spazio di spostamento nella verticale ...

Il new-line ha un altro effetto visibile quando si modifichi direttamente il testo, esso viene inserito quando viene premuto il tasto "Return" (o Invio) della tastiera principale, con riposizionamento del punto di inserimento, o del tasierino numerico, senza riposizionamento del punto di inserimento.
Dato che l'intercettazione dei tasti avviene sull'evento "KeyPress" che è antecedente al completamento del processo di rilascio, e che ho ritenuto di effettuare in quel frangente la valutazione delle righe (non considerando una riga vuota influente) vi troverete a scrivere su di una riga eccedente il limite posto, sin quando non darete "Invio"

png



Penso ci sia tutto ciò che serve sapere, il sottostante codice della finestra di test vi mostrerà le modalità di uso previste, qualora occorra, se volete provare il test copiate il codice in un file python e salvatelo nella stessa directory del modulo "mytktools.py" fornito prima (ovviamente con un nume diverso) e lanciatelo con una versione di python >= 3.10

CODICE
# -*- coding: utf-8 -*-

import os
import time
from threading import Thread
import tkinter as tk
import tkinter.filedialog as fd
import tkinter.messagebox as mb

from mytktools import BufferedText

def read_file(fname: str, f: callable, delay: float=0.1) -> None:
   with open(fname) as fn:
       txt = fn.read().splitlines()
   if not txt: return
   for t in txt:
       f(t)
       time.sleep(delay)

class MyApp(tk.Tk):
   def __init__(self) -> None:
       super().__init__()
       self.title('Test di BufferedText')
       self._wrap = False
       self._populate()
       self._lastdir = os.environ['HOME']

   def _populate(self) -> None:
       self.dida = tk.Label(self, text='')
       self.dida.grid(row=0, column=0, columnspan=2, padx=5, pady=5, sticky='w')
       self.tbox = BufferedText(self, wrap='none', font='courier 10')
       self.tbox.grid(row=1, column=0, padx=(5,1), pady=(5,1), sticky='nsew')
       vscroll = tk.Scrollbar(self, orient=tk.VERTICAL,
                              command=self.tbox.yview)
       vscroll.grid(row=1, column=1, padx=(1,5), pady=(5,1), sticky='ns')
       self.tbox.configure(yscrollcommand=vscroll.set)
       hscroll = tk.Scrollbar(self, orient=tk.HORIZONTAL,
                              command=self.tbox.xview)
       hscroll.grid(row=2, column=0, padx=(5,1), pady=(1,5), sticky='ew')
       self.tbox.configure(xscrollcommand=hscroll.set)
       pn_e = tk.Frame(self)
       pn_e.grid(row=3, column=0, columnspan=2, sticky='ew')
       dida = tk.Label(pn_e, text= 'Riga testo : ')
       dida.grid(row=0, column=0, padx=5, pady=5, sticky='w')
       self.e_row = tk.Entry(pn_e)
       self.e_row.grid(row=0, column=1, padx=5, pady=5, sticky='ew')
       bt_line = tk.Button(pn_e, text='Aggiungi riga', command=self._on_row)
       bt_line.grid(row=0, column=2, padx=5, pady=5, sticky='ew')
       pn_e.grid_columnconfigure(1, weight=1)
       pn_c = tk.Frame(self)
       pn_c.grid(row=4, column=0, columnspan=2, sticky='ew')
       dida = tk.Label(pn_c, text= 'Buff : ')
       dida.grid(row=0, column=0, padx=5, pady=5, sticky='w')
       self.e_buff = tk.Entry(pn_c, width=5, justify='right')
       self.e_buff.grid(row=0, column=1, padx=5, pady=5, sticky='ew')
       bt_buff = tk.Button(pn_c, text='Imposta buffer righe',
                           command=self._on_buff)
       bt_buff.grid(row=0, column=2, padx=5, pady=5, sticky='ew')
       self.bt_wrap = tk.Button(pn_c, text='Attiva wrap',
                           command=self._on_wrap)
       self.bt_wrap.grid(row=0, column=3, padx=5, pady=5, sticky='ew')
       self.bt_active = tk.Button(pn_c, text='Attiva area testo',
                                  command=self._on_active)
       self.bt_active.grid(row=0, column=4, padx=5, pady=5, sticky='ew')        
       bt_file = tk.Button(pn_c, text='Carica da file',
                           command=self._on_file)
       bt_file.grid(row=0, column=5, padx=5, pady=5, sticky='ew')
       bt_end = tk.Button(pn_c, text='Chiudi programma',
                           command=self.destroy)
       bt_end.grid(row=0, column=6, padx=5, pady=5, sticky='ew')
       for i in range(2,7):
           pn_c.grid_columnconfigure(i, weight=1, uniform='bt')

       self.grid_columnconfigure(0, weight=1)
       self.grid_rowconfigure(1, weight=1)

   def _on_row(self) -> None:
       txt = self.e_row.get()
       if txt:
           self.tbox.add_text(txt + '\n')
           self.e_row.delete(0, 'end')
       self._update_dida()

   def _on_buff(self) -> None:
       value = self.e_buff.get()
       if value.isnumeric():
           self.tbox.buffer = int(value)
       self._update_dida()

   def _on_active(self):
       active = self.tbox.is_active()
       if active:
           self.bt_active.configure(text='Attiva area testo')
           self.tbox.disable()
       else:
           self.bt_active.configure(text='Disattiva area testo')
           self.tbox.activate()
       self._update_dida()

   def _on_wrap(self) -> None:
       self._wrap = not self._wrap
       if self._wrap:
           self.tbox.configure(wrap='word')
           self.bt_wrap.configure(text='Disattiva wrap')
       else:
           self.tbox.configure(wrap='none')
           self.bt_wrap.configure(text='Attiva wrap')
       self._update_dida()
           
   def _on_file(self) -> None:
       types = [('File di testo', ('*.txt', '*.TXT')),
                ('Tutti i files', ('*.*',))]
       fname = fd.askopenfilename(parent=self,
                                  initialdir=self._lastdir,
                                  title='Seleziona file di testo',
                                  defaultextension='.txt',
                                  filetypes=types)
       if not fname: return
       if os.path.isfile(fname):
           self._lastdir = os.path.dirname(fname)
       try:
           t = Thread(target=read_file, args=(fname, self.add_text))
           t.daemon = True
           t.start()
       except OSError as e:
           mb.showerror("Errore file", f'{e}')

   def _update_dida(self) -> None:
       txt = 'Area di testo '
       if self.tbox.is_active():
           txt += 'Attiva - '
       else:
           txt += 'Disattiva - '
       if self.tbox.buffer:
           txt += f'Buffer di {self.tbox.buffer} righe - '
       else:
           txt += 'Nessun buffer di righe - '
       if self._wrap:
           txt += 'Ritorno a capo attivo'
       else:
           txt += 'Ritorno a capo NON attivo'
       self.dida.configure(text=txt)
       
   def add_text(self, txt: str) -> None:
       self.tbox.add_text(txt + '\n')


if __name__ == '__main__':
   app = MyApp()
   app.mainloop()
 
Web  Top
0 replies since 26/2/2024, 18:14   18 views
  Share