jarvisteach / appJar

Simple Tkinter GUIs in Python
http://appJar.info
Other
613 stars 68 forks source link

Windows not centred #389

Open mpmc opened 6 years ago

mpmc commented 6 years ago

Something I've noticed, if you start with a blank app and add widgets to it later the window is never centred on the screen..

An example..

notcentred

Not sure how this can be fixed, but it seems rather odd :p

jarvisteach commented 6 years ago

I've spent soooo much time trying to get window centring right, and still haven't managed it!

One issue is simply that every platform handles it differently, the second issue is that sometimes the GUI doesn't know how big it will be until it's been drawn.

When the GUI is initially placed on the screen - it should identify the middle of the screen, calculate the GUI's required width/height, do a bit of math, and then position itself appropriately.

This doesn't seem to be happening in the above example - it looks like it calculates it's position with no text - so slightly off centre - is the text added after app.go() is called?

If a GUI then has widgets added to it, the GUI won't move, instead it should simply stretch to accommodate the widgets - this is happening in the above, as the GUI's top left corner doesn't move.

It would be possible to have the GUI stick to the middle, by recalculating its position every time it is reconfigured, but that sounds painful, and not common to most apps.

Instead, you could call the CENTER() function when the gui is changed, which might adjust its position appropriately. (although I'm not sure I've tested this...)

mpmc commented 6 years ago

This doesn't seem to be happening in the above example - it looks like it calculates it's position with no text - so slightly off centre - is the text added after app.go() is called?

Yes, I'm queuing, dropping of the trousers for you to see what I'm doing :laughing:

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
#  gui6.py
#
#  Copyright 2018 Mark Clarkstone <git@markclarkstone.co.uk>
#
#  This program is free software; you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation; either version 2 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program; if not, write to the Free Software
#  Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
#  MA 02110-1301, USA.
#
#
import socket
import gettext
import traceback
import appJar
import hs602

gettext.install('Hs602util')

class Gui(appJar.gui):
    """Simple HS602 utility using appJar."""
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.title = '{}'.format(_('HS602 Utility'))
        self.addMenuEdit(False)
        self.controller = hs602.Callback()
        self.start()

    # Helper methods.
    @staticmethod
    def trace(exc):
        """Try and get traceback info.

        :param exc: Exception to get the trace for.
        """
        trace = ''
        try:
            trace = ''.join(traceback.format_tb(exc.__traceback__))
        except AttributeError:
            pass
        return trace

    def removeAllWidgets(self, *args, **kwargs):
        """This overrides the default removeAllWidgets so that the
        default padding can be reset properly."""
        super().removeAllWidgets(*args, **kwargs)
        self.padding = 8, 8
        self.inpadding = 5, 5

    def start(self, btn=None):
        """Trigger the default window."""
        self.trigger_discovery()

    def queueFunction(self, method, *args, **kwargs):
        """This overrides the default queueFunction so that we can
        catch Exceptions.

        :param method: Method to queue.
        :param args: Arguments to pass to the method.
        :param kwargs: Keywords arguments to pass to the method.

        """
        # Raise these rather than showing the error dialog to prevent
        # a loop.
        raise_only = [self.error, self.queueFunction]

        def run(*args, **kwargs):
            try:
                method(*args, **kwargs)
            except Exception as exc:
                if method not in raise_only:
                    print(exc)
                    self.error(exc)
                else:
                    raise exc
        super().queueFunction(run, *args, **kwargs)

    def values(self, **kwargs):
        """Get all values, merge & return as dictionary.

        :param kwargs: Optional keyword arguments - appended to the
        end of the input list.
        """
        # All available inputs.
        inputs = filter(None, [
                  self.getAllEntries(),
                  self.getAllOptionBoxes(),
                  self.getAllSpinBoxes(),
                  self.getAllListBoxes(),
                  self.getAllProperties(),
                  self.getAllCheckBoxes(),
                  self.getAllRadioButtons(),
                  self.getAllScales(),
                  self.getAllMeters(),
                  self.getAllDatePickers(),
                  kwargs,
        ])
        result = data = dict()
        for pairs in inputs:
            for key, val in pairs.items():
                # Try and strip values.
                try:
                    val = val.strip()
                except AttributeError:
                    pass
                try:
                    # Skip if value is empty or if key already exists.
                    if not val or result[key]:
                        continue
                except KeyError:
                    pass
                result[key] = val
        return result

    # User interface methods.
    def error(self, exc):
        """Display an error."""
        label = {
            'title': 'Message',
            'value': '{}'.format(_('Sorry there\'s a problem, the '
                                   'details of which are below.\nIf '
                                   'the problem persists, please '
                                   'report it.\n\nThank you!')),
            'pos': (0, 0),
            'sticky': 'nw',
            'stretch': 'none',
        }
        close = {
            'title': '{}'.format('Quit!'),
            'value': self.stop,
            'pos': (2, 0),
            'sticky': 'nesw',
            'stretch': 'none',
            'tip': '{}'.format(_('Close the Utility.')),
        }
        textarea = {
            'title': 'Error details',
            'value': '{}\n\n{}'.format(exc, self.trace(exc)),
            'pos': (1, 0),
            'sticky': 'nesw',
            'stretch': 'both',
        }

        def show():
            # Reset the GUI!
            self.removeAllWidgets()
            # Add widgets - error label (top).
            self.label(**label)
            self.setLabelAlign(label.get('title'), 'left')
            # Quit/Go back button (bottom).
            self.button(**close)
            # Text area.
            self.text(**textarea)
            self.disableTextArea(textarea.get('title'))

        # Show the error.
        self.queueFunction(show)

    def msg(self, text=None):
        """Clear the widgets and a show message - used during callbacks.

        text: Optional message to show.
        """
        label = {
            'title': 'Information',
            'value': '{}'.format(text or _('\u231B Action in-progress, '
                                           'please wait..')),
        }

        def show():
            # Clear and display the message.
            self.removeAllWidgets()
            self.label(**label)
            self.setLabelAlign(label.get('title'), 'center')

        self.queueFunction(show)

    def trigger_discovery(self, *args, **kwargs):
        """Trigger discovery callback."""
        msg = _('\u231B Searching for devices, please wait..')
        # Register the callback.
        self.controller.register('devices', callback=self.discovery)
        # Let the user know what's going on.
        self.msg(msg)

    def discovery(self, result=None):
        """Discovery and connection screen.

        :param result: Result of the callback (if one).
        """
        frame = {
            'title': '{}'.format(_('\u2605 Connection')),
            'sticky': 'nesw',
            'stretch': 'both',
            'padding': (5, 5),
        }
        info = {
            'title': 'Information',
            'value': '{}'.format(_('\u2139 To connect, select a device '
                                   'from the list (if shown) or enter '
                                   'an address.')),
            'pos': (0, 0, 2),
            'sticky': 'nw',
            'stretch': 'none',
        }
        device_list = {
            'title': 'addr',
            'pos': (1, 0, 2),
            'value': result,
            'sticky': 'nesw',
            'stretch': 'both',
        }
        connection_info = {
            'title': 'Connection Information',
            'value': '{}'.format('Just an address is required, the '
                                 'rest can be left blank.\nNote: '
                                 'If you enter a hostname and have '
                                 'slow DNS, connecting might take a '
                                 'while!'),
            'pos': (2, 0, 2),
            'sticky': 'nw',
            'stretch': 'none',
        }
        addr = {
            'title': 'addr',
            'pos': (3, 0),
            'default': '{}'.format(_('Device address')),
            'sticky': 'nesw',
            'stretch': 'both',
            'tip': '{}'.format(_('Enter a device address (usually an '
                                 'IP address).')),
        }
        timeout = {
            'title': 'timeout',
            'default': '{}'.format(_('Timeout')),
            'pos': (3, 1),
            'limit': 3,
            'kind': 'numeric',
            'sticky': 'nesw',
            'stretch': 'both',
            'tip': '{}'.format(_('Connection timeout (enter a value '
                                 'between 3-120 (seconds).')),
        }
        tcp = {
            'title': 'tcp',
            'pos': (4, 0),
            'default': '{}'.format(_('TCP port')),
            'limit': 5,
            'kind': 'numeric',
            'sticky': 'nesw',
            'stretch': 'both',
            'tip': '{}'.format(_('Device command port, leave blank '
                                 'to use default (8087).')),
        }
        udp = {
            'title': 'udp',
            'pos': (4, 1),
            'default': '{}'.format(_('UDP port')),
            'limit': 5,
            'kind': 'numeric',
            'sticky': 'nesw',
            'stretch': 'both',
            'tip': '{}'.format(_('Device broadcast port, leave blank '
                                 'to use default (8086).')),
        }
        scan = {
            'title': '{}'.format(_('\u26B2 Scan')),
            'value': self.trigger_discovery,
            'pos': (5, 0),
            'sticky': 'nesw',
            'stretch': 'both',
            'tip': '{}'.format('Scan your network.')
        }
        connect = {
            'title': '{}'.format(_('\u2713 Connect')),
            'value': self.connect,
            'pos': (5, 1),
            'sticky': 'nesw',
            'stretch': 'both',
            'focus': True,
            'tip': '{}'.format(_('Connect to the device!')),
        }

        def show():
            # Reset the window before displaying any widgets.
            self.removeAllWidgets()
            with self.labelFrame(**frame):
                self.label(**info)
                self.label(**connection_info)
                self.entry(**addr)
                self.entry(**timeout)
                self.entry(**tcp)
                self.entry(**udp)
                self.button(**scan)
                self.button(**connect)

                if result:
                    self.option(**device_list)

        # Bail if not a list.
        if not isinstance(result, list):
            return self.error(result)

        self.queueFunction(show)

    def connect(self, *args, **kwargs):
        """Initialise the connection to the device."""
        addr = {
            'title': '{}'.format(_('Error')),
            'message': '{}'.format(_('A valid address is required!')),
            'kind': 'error',
        }
        values = self.values(**kwargs)
        # Bail if address isn't valid.
        if not values.get('addr'):
            self.queueFunction(self.popUp, **addr)
            return

        # Setup the controller, setup the callback (requesting all
        # settings, and then show msg.
        self.controller.__init__(**values)
        self.controller.register('settings', callback=self.device)
        self.msg()

    def device(self, result):
        """The device screen - This is triggered via the callback.

        :param args: This should be a list of device settings or an
        exception.
        """
        error_popup = {
            'title': '{}'.format(_('Error')),
            'kind': 'error',
            'message': '{}\n\n{}\n\n{}'.format(
                _('There was a problem connecting!\nDebug info:-'),
                result,
                _('You\'ll be returned to the connect screen.'),
            ),
        }
        # Bail if result is not a dictionary.
        if not isinstance(result, dict):
            # Alert & return to the discovery screen, hide the list.
            self.queueFunction(self.popUp, **error_popup)
            self.queueFunction(self.discovery, list())
            return

        print(result)

def main(args):
    app = Gui(*args, useTtk=False)
    app.go()
    return 0

if __name__ == '__main__':
    import sys
    sys.exit(main(sys.argv))

I'd rather not have the window stuck, but to keep it centred unless the user moves or resizes. Hard nut to crack from the sounds of it!

mpmc commented 6 years ago

I've gone back to the drawing board (again) :rofl: and tried to use the center option, but the log just spams CENTER() missing 1 required positional argument: 'win'

jarvisteach commented 6 years ago

@mpmc - I've made some changes to setLocation & CENTER. If you now call setLocation('center') it should work.

I've tried adding a flag that keeps the window centered - and it works, but it is super laggy. The only way to have it done right is to recenter every time a widget is added once go() has been called - I don't think it's worthwhile keeping, so I've removed it.

mpmc commented 6 years ago

@jarvisteach I shall give it a go when I rewrite the app again hehe, atm I'm looking into creating a web 'app' version of my hs602 client. I still fully intend to create a standalone app using appJar :) & I hope to help you out where I can, I have some ideas that I'd like to discuss with you as well.

mpmc commented 6 years ago

@jarvisteach I had a think about this issue. A solution might be to add a flag that gives the option to trigger a call to setposition if the app starts with no widgets, and if widgets are added later, reset the position after they're drawn.

The only problem I can think of would be detecting when all widgets have been added/drawn. It's why I've done this..

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        # Defaults.
        self.title = _('HS602 Utility')  # Window title.
        self.addMenuEdit(inMenuBar=False)  # Right-click menus.
        self.controller = hs602.Controller()  # Controller object.
        self.padding = self._padding = 5, 5  # Widget outer padding.
        self.inPadding = self._inPadding = 5, 5  # Widget inner padding.
        self._cache = None  # settings cache.

        # Start.
        self.discovery()
        # Correct initial positioning.
        self.queueFunction(self.setLocation, 'center')