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
valueinstance variable (of type float). Usefocus_outto trigger update of instance variable. - Expose a
get_valuemethod 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_valuemethod that programmatically sets thevalueproperty 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
floatinstance variablesmin_valandmax_valand 2booleanvariablesmin_includedandmax_includedto 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
valueinstance variable. - Unformatted strings representing a valid floating value should be converted into formatted strings using the specified decimal and thousand separators. The
valueinstance 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
.gladefile (essentially an xml file containing all GUI layout information) and a.pyfile containing the application code and reading the information of the.gladefile with the GtkBuilder.
2.1 Custom catalog
Our custom catalog looks as follows:
<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.xmlThis 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.
(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
Signaltab 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 ourSmartEntryFloatclass 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:
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 = thousandsSeparator2.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
.gladelayout 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:
<?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.
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:
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.