Building a Linux app part 8: Adding an options screen

As you may have noticed, the repeater-START app doesn’t currently have an option to change that internal km/mile calculation. It also does not have any option to filter the repeaters of VHF, UHF or your preferred band.

Let’s see how to add a new dialog, then store the options, and load them on your program’s start:

Visual Form Dialog Design

The start of the GTK Repeater-START program was started off the OsmGpsMap example, but a more proper way to start off a form based app would be to design it in Glade:

Select new project in the upper left, and create a base top level GtkDialog

Note that each element needs a parent, a GtkWindow has one child, a GtkFrame framing similar controls needs one child, you will often be creating a GtkBox (in the containers dropdown) and setting it vertical or horizontal, for some number of items that will be laid out within the container. For example:

Add controls within GtkBox or GtkFrames, dragging from the top dropdowns or selecting from the top dropdown and clicking an empty space.

Note the “Group” attribute for radio buttons needs to be set to the base radio or multiple will display selected at the same time. That’s equivalent to new_from_widget like in the Python GTK3 examples.

Hit the run button: , fifth button on the bottom and you can preview the window once you have designed it. Create a name (“id”) of each widget you will be using from code. Note that the builder’s .get_object(id) will work for the ID that you choose in General as see in above image, not the “Widget Name” or any other properties you see in that right panel.

As seen in the Glade – GTK Builder documentation and examples, it should be expedient to set a class that does the work of the settings dialog itself, any signals that we want to use. So I set a class to load that file and hold its objects and settings…

Connecting Signals to your code

Once a class is set up to manage the secondary window, you should load it up from the main file. For “SettingsDialog” class I create as “SettingsDialog.py” I use:

from SettingsDialog import SettingsDialog

and a class property can be set as an initialized SettingsDialog on load –

class UI(Gtk.Window):
    def __init__(self):
        ...
        self.settingsDialog = SettingsDialog(self)
        ...
        self.pref_button = Gtk.Button()
        self.pref_button.set_image(Gtk.Image(icon_name="preferences-system",
                       icon_size=Gtk.IconSize.LARGE_TOOLBAR))
        self.pref_button.connect('clicked', self.pref_clicked)

    ...
    def pref_clicked(self,button):
        self.settingsDialog.show()

The SettingsDialog here will use the window(“self” in the main Gtk Window’s class) as the reference to the parent of this dialog (you could add any other extra parameters you need to the constructor as well.)

from gi.repository import Gtk
from gi.repository import Gdk

class SettingsDialog:
    def __init__(self, parentWin):
        builder = Gtk.Builder()
        builder.add_from_file('SettingsDialog.glade')
        builder.connect_signals(self)
        self.dialog = builder.get_object('SettingsDialog')
        #self.dialog.set_parent(parentWin)
        self.dialog.set_transient_for(parentWin) #Over the main window.
        self.dialog.set_modal(True)
        self.dialog.set_redraw_on_allocate(True)
        self.dialog.set_title('Settings')
        ...

You may have an issue with “GtkDialog mapped without a transient parent. This is discouraged” and this means the dialog you create aught to be associated to a main GtkWindow. To explore the documentation of this and other methods on the window/dialog options, you can use python3 console’s tab-completion helpers and the __doc__ method. For example at the terminal:

$ python3
 Python 3.6.9 (default, Oct  8 2020, 12:12:24) 
 [GCC 8.4.0] on linux
 Type "help", "copyright", "credits" or "license" for more information.
 >>>  from gi.repository import Gtk
       main:1: PyGIWarning: Gtk was imported without specifying a version first. Use gi.require_version('Gtk', '3.0') before import to ensure that the right version gets loaded.     
 >>>  g = Gtk.Dialog()
 >>>  g.set_transient_for.doc
       'set_transient_for(self, parent:Gtk.Window=None)'             

g.set_tr[TAB button] gives set_transient_for(, which is not too different from the C API.

Also, set_modal(True) to keep it showing over the middle of the main window.

Now I set a certain button to show_all() on the named dialog. In the Signals tab you can define functions you want to call on certain events. You will most likely want to call your code on closing of the dialog, and you can see the documentation of each event of the window (or button or other widget selected) by clicking the D button next to the signal name as shown:

Note that connect_signals(self) on this class makes those functions call on their respective events shown above. Now you should see the dialog when show() is called:

That’s great, but you may notice a second call to show() on that dialog fails. In fact with the onDestruct signal set:

you can see in the terminal that onDestroy is being called, with a print statement in that function. So on calling show, that should create a dialog, not re use the dialog variable that is destroyed. There are two ways as documented here to build your form – show and hide one form, or destroy and gain back memory and create it again (As described in this article, which unfortunately shows an ancient Python-pygtk example). In this gtkDialog case it seems to destroy the dialog window upon close (response is when a bottom button is pressed to finish the dialog), so we can re-create it in a show() element on the class. However, the settings should be available to the whole application in the class variables whether the window is showing or destroyed or not set up yet…

Saving, Loading the settings

There are various ways you can save your application settings. There is Gnome Gconf and Dconf but those are editable through specific applications and are not so multi-platform-friendly. An easier way, that makes it easy to clear out your settings by removing a file, lets you test the first set up easier. You can use only standard Python libraries to read/write files. You could write in JSON, XML, or other format, but the old style INI text file is widely used and easily editable by humans too. I use the previously used userFile(‘filename.ini’) as described in the previous post, from the parentWin’s class, and here I create a ini file with configparser:

import gi
import os
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
from gi.repository import Gdk
import configparser

class SettingsDialog:
    def __init__(self, parentWin):
        self.parentWin = parentWin
        self.config = configparser.ConfigParser()
        if os.path.exists(parentWin.userFile('settings.ini')):
            self.config.read(parentWin.userFile('settings.ini'))
        else: #defaults:
            self.config['ViewOptions'] = {
             'unitsLength': 'mi',
             'filterMin' : '',
             'filterMax' : ''
            }
        #Just ham radio constants:
        self.UHFMIN = '420'
        self.UHFMAX = '450'
        self.VHFMIN = '144'
        self.VHFMAX = '148'
            
    def writeSettings(self):
        # Save the config to the file:
        units = 'mi'
        if self.builder.get_object('distUnitsRadioKM').get_active():
            units = 'km'
        self.config['ViewOptions'] = {
         'unitsLength': units,
         'filterMin' : self.builder.get_object('lblMinFreq').get_text(),
         'filterMax' : self.builder.get_object('lblMaxFreq').get_text()
        }
        with open(self.parentWin.userFile('settings.ini'),'w') as outfile:
            self.config.write(outfile)
    
    def getMinFilter(self):
        value = self.config['ViewOptions']['filterMin']
        try:
            return float(value)
        except ValueError:
            return -1

    def getMaxFilter(self):
        value = self.config['ViewOptions']['filterMax']
        try:
            return float(value)
        except ValueError:
            return 9e999

    def getUnit(self):
        return self.config['ViewOptions']['unitsLength']

    def show(self):
        #Create GtkDialog
        self.builder = Gtk.Builder()
        self.builder.add_from_file('SettingsDialog.glade')
        self.builder.connect_signals(self)
        self.dialog = self.builder.get_object('SettingsDialog')
        #self.dialog.set_parent(parentWin)
        self.dialog.set_transient_for(self.parentWin) #Over the main window.
        self.dialog.set_modal(True)
        #self.dialog.set_redraw_on_allocate(True)
        self.dialog.set_title('Settings')
        self.dialog.show_all()
        #Load the config to the form
        options = self.config['ViewOptions']
        if options['unitsLength'] == 'km':
            self.builder.get_object('distUnitsRadioKM').set_active(True)
        if self.getMinFilter() > 0:
            self.builder.get_object('filterFreqCustom').set_active(True)
            # ^ if nofilter is selected, the default, then they are cleared with NoFilterSet()
            self.builder.get_object('lblMinFreq').set_text(str(options['filterMin']))
        if self.getMaxFilter() < 9e999:
            self.builder.get_object('filterFreqCustom').set_active(True)
            self.builder.get_object('lblMaxFreq').set_text(str(options['filterMax']))
        if self.getMinFilter() == float(self.VHFMIN) and self.getMaxFilter() == float(self.VHFMAX):
            self.builder.get_object('freqFilterVHF').set_active(True)
        if self.getMinFilter() == float(self.UHFMIN) and self.getMaxFilter() == float(self.UHFMAX):
            self.builder.get_object('freqFilterUHF').set_active(True)

    # User actions on the form:
    def NoFilterSet(self, *args):
        print('nofilter')
        print(args)
        self.builder.get_object('lblMinFreq').set_text('')
        self.builder.get_object('lblMaxFreq').set_text('')

    def VHFSet(self, *args):
        self.builder.get_object('lblMinFreq').set_text(self.VHFMIN)
        self.builder.get_object('lblMaxFreq').set_text(self.VHFMAX)
        
    def UHFSet(self, *args):
        self.builder.get_object('lblMinFreq').set_text(self.UHFMIN)
        self.builder.get_object('lblMaxFreq').set_text(self.UHFMAX)

    def onDestroy(self, *args):
        pass
        
    # Note this is also set as the close button's clicked signal.
    def onClosed(self, *args):
        self.writeSettings()
        self.parentWin.displayNodes()
        self.parentWin.refreshListing()
        self.dialog.destroy()

In the above you can see three main parts,

  • in __init__ loading up settings from file – with some sane defaults set in the case os.path.exists is false.
  • Convenience functions to interpret the setting – and give sane defaults – for example none/unset minimum, with a ValueError in getMinFilter above, can give a default on initial or unset states.
  • create form and load data to/from the form choices on show() (note that the default selection of “show all” was causing some issues here as it was “clicking” no filter and blanking setting, hence set_active on the custom radio selector.
  • saving on window close, which you can verify by closing and opening up with the same settings set.

Once you have your settings dialog and relevant functionality all working together, don’t forget to try the inital case by deleting the ~/.local/share/appname/settings.ini or other files that the save generates. See the full code here.

Mobile Testing

Once you have settings dialog set up like you want it, it’s time to make sure the functionality also works on mobile. If you don’t have a Librem/Pinephone you can use the emulator as described in packaging howto:

Build .deb:

dpkg-buildpackage -b -rfakeroot -us -uc

Copy:

scp -r -P 2222 ./repeater-start_0.5_all.deb purism@localhost:/home/purism/

and in Librem’s terminal enter:

sudo dpkg -i ./repeater-start*
repeaterSTART

Adjustments for Librem 5

Now I noticed the toolbar is too wide, making the window out of screen. As noted in previous article, it just need some adjustment – in this case, the wide add repeater button can just be a icon button on smaller screens…

        if self.mainScreen.get_width() < 800:
            #Just room for icon on Librem/phone.
            self.add_button = Gtk.Button()
            self.add_button.set_image(Gtk.Image(icon_name="list-add",
                       icon_size=Gtk.IconSize.LARGE_TOOLBAR))
        else:
            self.add_button = Gtk.Button('Add Repeater')

Now with this small change, the app looks right, and settings dialog works as on desktop!

Leave a Reply

Your email address will not be published. Required fields are marked *

sixty three − 57 =