The GtkEntry
widget is pretty dumb: Whatever input is sent to the entry field (whether by user input or by the set_text
method) is treated as a String
. Time to beef up the GtkEntry with an extension that we call SmartEntryFloat. This post is a sequel to my introductory post on creating custom widgets in PyGtk and integrating them into the Glade UI designer.
1. What we would like to have
Our SmartEntryFloat should be an entry field that is particularly geared towards numeric input. We would like to expect the following features:
1.1 Logical separation between actual numeric float
value and String
representation displayed or entered
- Entry of a String representing a valid float is directly converted and stored into a
value
instance variable (of type float). Usefocus_out
to trigger update of instance variable. - Expose a
get_value
method that returns the content of the float instance variable value. No more need for clumsy cast operations every time we want to process a numeric content from aGtkEntry
. - Maybe a
set_value
method that programmatically sets thevalue
property of our SmartEntryFloat object and the String in the entry field. - Assure two way synchronization between the float number held in the value instance variable and the String displayed in the field. Programmatically changing the value variable should trigger an update in the String displayed. Changing the String representation of the entry should change the float value.
1.2 Consistency checking of user input
- Check if entered String can be cast to a reasonable float at all – if not warn user by setting the background color of the entry field to red.
- Add option to specify allowed numerical range of input. E.g. a smart entry field for the height of a person in meters should only contain values higher than 0 and less than 3 meters. Use 2
float
instance variablesmin_val
andmax_val
and 2boolean
variablesmin_included
andmax_included
to define the numerical intervals of values allowed.
override_background_color
and modify_bg_color
in a single line of code, Python issues a warning that these methods have been officially declared deprecated. Instead Gtk expects the developers to use CSS to alter the background color – which is way more complicated than coloring the background with a single command.I am going to dedicate a separate text on how to use CSS in pyGtk.
1.3 Formatted String representation
- Specify decimal seperators and thousand seperators as class variables.
- Specify number of decimal places displayed. In case the actual value has more decimal places than displayed according to the formatting, a hint to the user should be given (e.g. yellow background when focus out, on focus in, the entry should display the number with full accuracy so that the user can check the non-rounded value by tabbing into the field).
- Consistency checks for correctly formatted strings that represent input numbers should be interpreted as valid input and internally cast to a float and stored into the
value
instance variable. - Unformatted strings representing a valid floating value should be converted into formatted strings using the specified decimal and thousand separators. The
value
instance variable should be updated accordingly.
1.4 Further improvements
In order to make sure that the value instance variable is updated even when the SmartEntry has not lost focus, we could also use the changed
signal to set a Timer
object (instance variable) in motion. While editing still occurs and new changed signals are fired. The timer is cancelled and restarted again and again. Once the entry object stops firing changed
events and the timer expires, consistency checks are performed, the value
instance variable is updated and formatting is done.
2. What we need
As already described in the previous post on custom widgets in pyGTK and Glade, we need:
- a custom catalog xml file that tells Glade all necessary info about our custom widget: what’s its name, what widget group it should belong to, from what base class it derives its properties and signals that we can set in Glade, the icon that should be used for the widget in the toolbox, etc.
- a custom module file that holds the programmatical logic of the icon: how it should respond to signals, what additional properties and methods it should feature, what override methods there should be. This is a Python class file.
- While it is not strictly speaking a component of the custom widget itself, we need a test driver GUI application: this is a
.glade
file (essentially an xml file containing all GUI layout information) and a.py
file containing the application code and reading the information of the.glade
file with the GtkBuilder.
2.1 Custom catalog
Our custom catalog looks as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<glade-catalog name="SmartWidgets" library="gladepython" domain="glade-3" depends="gtk+"> <!--<init-function>glade_python_init</init-function> --> <glade-widget-classes> <glade-widget-class title="SmartEntryFloat" name="SmartEntryFloat" generic-name="smart-entry-float" parent="GtkEntry" icon-name="widget-gtk-entry"/> </glade-widget-classes> <glade-widget-group name="python" title="Python"> <glade-widget-class-ref name="SmartEntryFloat"/> </glade-widget-group> </glade-catalog> |
/usr/share/glade/catalogs/SmartWidgets.xml
This way we can reuse the catalog any time we open Glade without having to specify an additional catalog location under preferences.
Even though we don’t have added the code module yet, Glade will already feature an icon for our SmartWidgetFloat in its toolbox:
When we launch Glade from the console by typing glade
, though, we can see that Glade produces some warnings related to our tool. These warnings are issued because there is no module file yet. So the next thing we come up with is our Python file that determines the logic of the class.
1 2 3 |
(glade:61403): GladeUI-WARNING **: 02:13:06.981: We could not find the symbol "smart_entry_float_get_type" (glade:61403): GladeUI-WARNING **: 02:13:06.981: Could not get the type from "SmartEntryFloat" |
2.2 Module file
The module file for the AwesomeTextView
custom widget in my previous demonstration lacked one essential feature: There were no overrides for signal handlers (such as how to respond to events like clicked
or changed
). I would not have known how to write callback functions in pyGTK if it had not been for a post on stackoverflow.com. Also helpful on how to connect the callback was another discussion, again on stackoverflow.com. Although both posts relate to the now deprecated Gtk2 world, they were still helpful in guessing my way to a comparable solution in Gtk3.
2.2.1 Handling signals emitted by our custom widgets
Whenever we want to handle a signal emitted by our SmartEntryFloat, we have to perform 3 steps:
- Find out the exact signal name – this is where Glade helps a lot because the
Signal
tab in the right pane lists all standard signals for a widget. E.g., the signal that responds to entry field losing the focus isfocus-out-event
. - Connect the signal to a standard handler function (‘callback’) in the module code. This can normally happen in the
__init__
function of the class as it is part of the usual housekeeping that happens when an instance of ourSmartEntryFloat
class is created. We have to issue a command likeself.connect('focus-out-event', self.on_focus_out_event)
. Notice that the first argument is the exact signal id string we found out in 1. The second argument is a reference to the callback function that we want to be carried out once the signal is triggered. The usual naming convention is on_ followed by the signal name with dashes converted to undescores (i.e. on_focus_out_event) - Write the callback function, which tells the SmartWidget what to do when the particular event connected to the function has occured.
So to make a long story short, this is what my module file looks like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 |
from gi.repository import Gtk, Gdk, Pango import uuid class SmartEntryFloat(Gtk.Entry): __gtype_name__ = 'SmartEntryFloat' decSeparator = "." kSeparator = "'" font = 'Monotype' def __init__(self): Gtk.Entry.__init__(self) self.connect('focus-out-event', self.on_focus_out_event) self.connect('focus-in-event', self.on_focus_in_event) self.value = None self.numberDecimals = -1 # neg 1 = leave places as they are self.restrictions = {'minVal': None, 'maxVal': None, 'minInclude': False, 'maxInclude': False} self.cssId = str(uuid.uuid1()) self.styleContext = None self.status = {'empty': False, 'conv_err': False, 'rng_err': False, 'dec_warn': False} self.css_amends() def on_focus_in_event(self, widget, event): if not self.status['empty']: displayString = self.get_formatted_value(blFullPrecision=True) self.set_text(displayString) def on_focus_out_event(self, widget, event): userInput = self.get_text().strip() if userInput == "": self.value = None self.status['empty'] = True self.status['conv_err'] = False self.status['rng_err'] = False self.status['dec_wan'] = False self.styleContext.remove_class('error') self.styleContext.remove_class('warn') else: self.value = self.validate_input(userInput) if self.value: self.status['empty'] = False self.set_text(self.get_formatted_value()) self.update_dec_warn() if self.status['conv_err'] or self.status['rng_err']: self.styleContext.remove_class('warn') self.styleContext.add_class('error') else: self.styleContext.remove_class('error') if self.status['dec_warn']: self.styleContext.add_class('warn') else: self.styleContext.remove_class('warn') def css_amends(self): self.set_name(self.cssId) screen = Gdk.Screen.get_default() gtk_provider = Gtk.CssProvider() gtk_context = Gtk.StyleContext() gtk_context.add_provider_for_screen(screen, gtk_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) cssErr = "#" + self.cssId + ".error {background-image: image(red);}" cssWarn = "#" + self.cssId + ".warn {background-image: image(yellow);}" cssSet = cssErr + '\n' + cssWarn gtk_provider.load_from_data(bytearray(cssSet, 'utf8')) self.styleContext = self.get_style_context() self.set_font(SmartEntryFloat.font) def set_font(self, fontDescr): self.modify_font(Pango.FontDescription(fontDescr)) def set_decimal_places(self, numDecimals=-1): self.numberDecimals = numDecimals def set_range_allowed(self, minVal=None, maxVal=None, minInclude=False, maxInclude=False): self.restrictions['minVal'] = minVal self.restrictions['maxVal'] = maxVal self.restrictions['minInclude'] = minInclude self.restrictions['maxInclude'] = maxInclude def set_value(self, floatVal): ''' Sets the value and the display to a float value. Assumes that a regular float value is passed and does not trigger any error coloring of the input field. Yellow coloring is triggered though.''' self.value = self.validate_input(str(floatVal)) if self.value: self.status['empty'] = False self.set_text(self.get_formatted_value()) self.update_dec_warn() if self.status['dec_warn']: self.styleContext.add_class('warn') else: self.styleContext.remove_class('warn') else: self.set_text("") self.status['empty'] = False self.status['conv_err'] = False # No coloring b/c not user induce self.status['rng_err'] = False self.status['dec_warn'] = False self.styleContext.remove_class('error') self.styleContext.remove_class('warn') def validate_input(self, strIn): strInput = strIn.replace(SmartEntryFloat.kSeparator, '').strip() floatVal = None try: floatVal = float(strInput) except ValueError: self.status['conv_err'] = True return None self.status['conv_err'] = False blMinValOK = blMaxValOK = True if self.restrictions['minVal'] is not None: if self.restrictions['minInclude']: blMinValOK = floatVal >= self.restrictions['minVal'] else: blMinValOK = floatVal > self.restrictions['minVal'] if blMinValOK is False: self.status['rng_err'] = True return None if self.restrictions['maxVal'] is not None: if self.restrictions['maxInclude']: blMaxValOK = floatVal <= self.restrictions['maxVal'] else: blMaxValOK = floatVal < self.restrictions['maxVal'] if blMaxValOK is False: self.status['rng_err'] = True return None self.status['conv_err'] = self.status['rng_err'] = False return floatVal def update_dec_warn(self): """Checks if value displayed has a loss of precision against the actual numerical value stored whenever self.numDecimals > -1. Will adjust self.status['dec_warn'] accordingly and return the updated value.""" if self.get_text() == "" or self.numberDecimals == -1: self.status['dec_warn'] = False return False strDisplayed = self.get_text().strip() cleanString = strDisplayed.replace(SmartEntryFloat.kSeparator, "") strValStored = str(self.value) if len(strValStored) > len(cleanString): self.status['dec_warn'] = True return True else: self.status['dec_warn']= False return False def get_formatted_value(self, *, blFullPrecision=False): if self.value is None: return "" strPreformattedValue = "" if self.numberDecimals == -1 or blFullPrecision: strPreformattedValue = f"{self.value:,}" else: preFromatStr = "{:,." + str(self.numberDecimals) + "f}" strPreformattedValue = preFromatStr.format(self.value) strPreformattedValue = strPreformattedValue.replace(',', "k") strPreformattedValue = strPreformattedValue.replace('.', "d") strPreformattedValue = strPreformattedValue.replace('k', SmartEntryFloat.kSeparator) strPreformattedValue = strPreformattedValue.replace('d', SmartEntryFloat.decSeparator) return strPreformattedValue def set_global_separators(*, decimalSeparator=None, thousandsSeparator=None): if decimalSeparator == thousandsSeparator: return if decimalSeparator is not None and thousandsSeparator is not None: SmartEntryFloat.decSeparator = decimalSeparator SmartEntryFloat.kSeparator = thousandsSeparator return if decimalSeparator is not None: if SmartEntryFloat.kSeparator != decimalSeparator: SmartEntryFloat.decSeparator = decimalSeparator return if thousandsSeparator is not None: if SmartEntryFloat.decSeparator != thousandsSeparator: SmartEntryFloat.kSeparator = thousandsSeparator |
2.3 The test driver program
As a testing application we need to set up:
- A Glade layout which features two of our SmartEntryFloat widgets. Basically one widget would be enough to test – but in order to make sure that CSS-driven coloring of the background colors does only affect the entry object where an erroneous input took place and not all widgets, we prefer to run a test in a layout with more than one SmartEntryFloat widget.
- A Python code that picks up the
.glade
layout file and sets up our custom widget (e.g. font, decimal places displayed, numerical range allowed if any)
So here’s what the .glade file looks like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
<?xml version="1.0" encoding="UTF-8"?> <!-- Generated with glade 3.22.2 --> <interface> <requires lib="gtk+" version="3.20"/> <requires lib="SmartWidgets" version="0.0"/> <object class="GtkWindow" id="win"> <property name="can_focus">False</property> <signal name="delete-event" handler="on_win_delete_event" swapped="no"/> <child type="titlebar"> <placeholder/> </child> <child> <object class="GtkBox"> <property name="visible">True</property> <property name="can_focus">False</property> <property name="orientation">vertical</property> <child> <object class="GtkButton" id="btn1"> <property name="label" translatable="yes">button</property> <property name="visible">True</property> <property name="can_focus">True</property> <property name="receives_default">True</property> <signal name="clicked" handler="on_btn1_clicked" swapped="no"/> </object> <packing> <property name="expand">False</property> <property name="fill">True</property> <property name="position">0</property> </packing> </child> <child> <object class="SmartEntryFloat" id="sefInput"> <property name="visible">True</property> <property name="can_focus">True</property> <property name="xalign">1</property> </object> <packing> <property name="expand">False</property> <property name="fill">True</property> <property name="position">1</property> </packing> </child> <child> <object class="GtkButton" id="btn2"> <property name="label" translatable="yes">button</property> <property name="visible">True</property> <property name="can_focus">True</property> <property name="receives_default">True</property> </object> <packing> <property name="expand">False</property> <property name="fill">True</property> <property name="position">2</property> </packing> </child> <child> <object class="SmartEntryFloat" id="sefAnother"> <property name="visible">True</property> <property name="can_focus">True</property> </object> <packing> <property name="expand">False</property> <property name="fill">True</property> <property name="position">3</property> </packing> </child> </object> </child> </object> </interface> |
And here’s our Python test driver code. Notice that it imports our SmartEntry module file in line 4. The previous .glade file is imported by the builder instruction in line 12.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
import gi gi.require_version('Gtk', '3.0') from gi.repository import Gtk from SmartWidgets import SmartEntryFloat class App(): builder = Gtk.Builder() def __init__(self): self.builder.add_from_file('testdriver.glade') win = self.builder.get_object('win') sefInput = self.builder.get_object('sefInput') sefInput.set_range_allowed() sefInput.set_font('Monospace') sefInput.set_decimal_places(2) #sefInput.set_range_allowed(minVal=50, minInclude=True, maxVal=100, maxInclude=True) sefInput.set_value(63.4554) win.show() def on_win_delete_event(self, widget): Gtk.main_quit() if __name__ == '__main__': app = App() Gtk.main() |
3. Where to put which file
Apart from the custom catalog SmartWidgets.xml
for Glade which resides in /usr/share/glade/catalogs/
on my I have so far placed the remaining files (module file, test driver program and .glade definition) of the GUI layout into a single development directory.
But where should the Python module file go if we want to have it in place to be imported for any other development project we are having in place on our machine? I followed the advice of this discussion on stackoverflow.com:
1 2 |
ilek@i7:~$ python3 -m site --user-site /home/ilek/.local/lib/python3.8/site-packages |
So my custom modules go to the directory shown in line 2 of the output above.