New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

removeAllWidgets breaks rightClickMenu #347

Closed
mpmc opened this Issue Jan 14, 2018 · 8 comments

Comments

Projects
None yet
2 participants
@mpmc
Contributor

mpmc commented Jan 14, 2018

Using the latest next_release code barfs out the following, when hitting my error info window.

Exception in Tkinter callback
Traceback (most recent call last):
  File "/usr/lib/python3.6/tkinter/__init__.py", line 1702, in __call__
    return self.func(*args)
  File "/usr/lib/python3.6/tkinter/__init__.py", line 748, in callit
    func(*args)
  File "/home/mark/.local/lib/python3.6/site-packages/appJar/appjar.py", line 1972, in __processEventQueue
    func(*args, **kwargs)
  File "gui.py", line 238, in show
    self.addMenuEdit(inMenuBar=False)
  File "/home/mark/.local/lib/python3.6/site-packages/appJar/appjar.py", line 9579, in addMenuEdit
    self.widgetManager.add(self.Widgets.Menu, "EDIT", editMenu)
  File "/home/mark/.local/lib/python3.6/site-packages/appJar/appjar.py", line 13581, in add
    raise ItemLookupError("Duplicate key: '" + widgetName + "' already exists")
appJar.appjar.ItemLookupError: Duplicate key: 'EDIT' already exists

To reproduce..

  • Run my WIP code.
  • Get to the manual connection part, enter a fake address, hit connect & view the debug log.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
#  gui.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 gettext
import traceback
import appJar
import hs602

gettext.install('Hs602util')


class Gui(appJar.gui):
    """Simple HS602 utility using appJar."""
    def __init__(self, *args):
        # Initialise parent.
        super().__init__()
        self.title = _('HS602 Utility')
        # GUI Settings.
        self.setTitle('{}'.format(self.title))
        # Theme.
        self.useTtk()
        self.setTtkTheme('clam')

        # HS602.
        self.controller = hs602.Controller()
        self.controller_cache = {}

        # Launch!
        self.launch(*args)

    # Helper methods.
    # Small generic methods.
    def widget_defaults(self, **kwargs):
        """Set widget defaults.

        :param inside: Set inside padding instead?
        :param sticky: Sticky value (default nesw).
        :param stretch: Stretch value (default both).
        :param expand: Expand value (default none).
        """
        # Outside the widget.
        out_x = 5
        out_y = 5
        # Inside the widget.
        in_x = 5
        in_y = 5

        # Get the values.
        stretch, inside, sticky, expand = [
            kwargs.get('stretch', 'both'),
            kwargs.get('inside', False),
            kwargs.get('sticky', 'nesw'),
            kwargs.get('expand'),
        ]
        # Call!
        self.setSticky(sticky)
        self.setStretch(stretch)

        if expand:
            self.setExpand(expand)

        if inside is True:
            self.setInPadding(in_x, in_y)
        else:
            self.setPadding(out_x, out_y)

    def callback(self, method, callback=None, *args, **kwargs):

        """Simple (queued) callback.

        :param method: Method to thread.
        :param callback: Optional method to receive the result.
        :param args: Positional arguments (for the threaded method).
        :param kwargs: Keyword arguments (for the threaded method).
        """
        text = _('Callback "{}" isn\'t callable').format(callback)

        def run():
            """Run the method in a new thread, catch errors."""
            try:
                ret = method(*args, **kwargs)
                # Queue the run_callback!
                self.queueFunction(run_callback, ret)
            except Exception as exc:
                self.error(exc)

        def run_callback(result):
            """Run the callback, pass the result, catch errors."""
            try:
                # Only run if callable!
                if callable(callback):
                    callback(result)
                else:
                    self.warn(text)
            except Exception as exc:
                self.error(exc)

        # Run!
        self.thread(run)

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

        kwargs will be _appended_ to the input list.

        :param convert: Convert.
        """
        merge = dict()
        convert = kwargs.get('convert', True)

        # All available inputs.
        inputs = [
                  self.getAllOptionBoxes(),
                  self.getAllEntries(),
                  self.getAllCheckBoxes(),
                  self.getAllRadioButtons(),
                  self.getAllProperties(),
                  self.getAllScales(),
                  self.getAllMeters(),
                  self.getAllSpinBoxes(),
                  self.getAllListBoxes(),
                  self.getAllDatePickers(),
                  kwargs,
        ]

        for dictionary in inputs:
            merge.update(dictionary)
            for key, value in merge.items():
                # Remove start/end spaces from values.
                if isinstance(value, str):
                    merge[key] = value.strip()
                if convert is True:
                    if isinstance(value, float):
                        merge[key] = int(round(value))

        return merge

    def launch(self, *args):
        """Start the app - this just launches discover for now."""
        self.discover()
        
    def reset_window(self, toolbar=False):
        """Reset the window.

        :param toolbar: Add toolbar after the reset?
        """
        self.removeAllWidgets()
        self.removeToolbar()
        self.widget_defaults()
        if toolbar is True:
            self.window_toolbar()

    # Window methods.
    # These are the methods that draw/manage the GUI.
    def window_toolbar(self):
        """Generic window toolbar."""
        # Toolbar titles & methods.
        toolbar = {
            _('Status'): 'main',
            _('Settings'): 'settings',
            _('About'): 'about',
        }
        # Combined toolbar.
        toolbar_list = [name for name, method in toolbar.items()]

        def menu_action(button=None):
            button = '{}'.format(toolbar.get(button))
            method = getattr(self, button)
            method()

        # Add toolbar.
        self.addToolbar(toolbar_list, menu_action)

    def error(self, exc):
        """Display an error."""
        # Properties.
        # Label.
        label_text = _('Sorry there\'s a problem, the details of '
                       'which are below.\nIf the problem persists, '
                       'please report it.\n\nThank you.')
        # Button.
        button_text = _('Quit')
        button_tooltip = _('Close the utility')
        # Textarea.
        textarea_text = _('No traceback available')
        # Attempt to get the traceback.
        try:
            textarea_text = ''.join(traceback.format_tb(
                                    exc.__traceback__))
        except AttributeError:
            pass

        # Format.
        textarea_text = _('{}\n\n{}').format(exc, textarea_text)
        label_text = '{}'.format(label_text)
        button_text = '{}'.format(button_text)

        def show():
            """Display the error."""
            # Reset the GUI!
            self.reset_window()

            self.widget_defaults(sticky='ew', stretch='none')
            # Add widgets - error label (top).
            self.addLabel(label_text, label_text, 0, 0)
            self.setLabelAlign(label_text, 'left')

            # Quit button.
            self.addButton(button_text, self.stop, 2, 0)
            self.setButtonTooltip(button_text,
                                  '{}'.format(button_tooltip))

            # Text area.
            self.widget_defaults()
            self.addScrolledTextArea(textarea_text, 1, 0)
            self.setTextArea(textarea_text, textarea_text)
            self.disableTextArea(textarea_text)
            self.addMenuEdit(inMenuBar=False)

        # To prevent weird things happening, queue.
        self.queueFunction(show)

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

        text: Optional message to show.
        """
        text = text or _('\u231B Please wait..')
        text = '{}'.format(text)

        # Clear and display the message.
        self.reset_window()
        self.addLabel(text, text)
        self.setLabelAlign(text, 'center')

    def discover(self, *args, **kwargs):
        """Discover devices."""
        # Interlude message.
        interlude = _('\u23F2 Probing devices, please wait..')
        # No devices found prompt.
        nodev_title = _('No Devices')
        nodev_text = _('No devices were found on your network. :(\n'
                       'Scan again (Retry) or connect manually '
                       '(Cancel)?')
        # Frame title.
        frame = _('Available Devices')
        # Info label.
        label = _('Which device do you want to connect to?')
        # Connect button.
        connect = _('\u260D Connect')
        # Re-scan button.
        rescan = _('\u26B2 Re-scan')
        # Manual button.
        manual = _('\u25C9 Manual connection')

        def probe():
            """Probe for devices."""
            return self.controller.devices

        def show(result):
            """Show devices."""
            # No devices?
            if len(result) == 0:
                question = self.retryBox('{}'.format(nodev_title),
                                         '{}'.format(nodev_text))
                if question in ['yes', True, _('yes')]:
                    self.discover()
                else:
                    self.manual()
                return

            # Which do we connect to?
            self.reset_window()

            # These buttons are outside the frame.
            self.widget_defaults(sticky='nesw')
            self.addButton('{}'.format(rescan), self.discover, 2, 0, 1)
            self.addButton('{}'.format(manual), self.manual, 2, 1, 1)

            # Display the address.
            with self.labelFrame('{}'.format(frame), 0, 0, 2):
                self.widget_defaults()
                # Info label.
                info = '{}'.format(label)
                self.addLabel(info, info, 0, 0, 2)
                self.setLabelAlign(info, 'center')
                # Option box & connect button.
                self.addOptionBox('addr', result, 1, 0, 1)
                self.addButton('{}'.format(connect), self.connect,
                               1, 1, 1)

        self.interlude(interlude)
        self.callback(probe, show)

    def manual(self, btn=None):
        """Manual connection.

        :param btn: The button that called this method (not used).
        """
        rescan = _('\u2753 Re-scan')
        connect = _('\u260D Connect')
        frame = _('Manual Connection')
        address = _('Address')
        tcp = _('TCP')
        timeout = _('Timeout')
        info = _('\u2139 Just an (IP) address is required, leave the '
                 'rest blank to use the defaults.')
        # Reset to defaults.
        self.reset_window()

        # Buttons!
        self.addButton('{}'.format(rescan), self.discover, 5, 0, 1)
        self.addButton('{}'.format(connect), self.connect, 5, 1, 1)

        # Frame - before the above buttons.
        with self.labelFrame('{}'.format(frame), 0, 0, 2):
            self.widget_defaults()
            # Entry.
            self.addEntry('addr', 0, 1)
            self.addNumericEntry('tcp', 1, 1)
            self.addNumericEntry('timeout', 3, 1)
            self.setFocus('addr')
            self.setEntryMaxLength('tcp', 5)
            self.setEntryMaxLength('timeout', 3)
            # Labels.
            self.addLabel('addr', '{}'.format(address), 0, 0)
            self.addLabel('tcp', '{}'.format(tcp), 1, 0)
            self.addLabel('to',  '{}'.format(timeout), 3, 0)
            self.addLabel('info', '{}'.format(info), 4, 0, 2)
            self.setLabelAlign('info', 'center')
            self.setLabelAlign('addr', 'center')
            self.setLabelAlign('tcp', 'center')
            self.setLabelAlign('to', 'center')
            self.addMenuEdit(inMenuBar=False)

    def connect(self, *args, **kwargs):
        """Connect to the device."""
        # Interlude message.
        interlude = _('\u260D Connecting, please wait..')
        # Address.
        address_title = _('Oops!')
        address_text = _('An address is required/Address is '
                         'Invalid!')
        # Invalid response.
        settings_title = _('Sorry!')
        settings_text = _('There was a problem getting settings! :(\n'
                          'This may indicate that your device has '
                          'crashed, or you have a network issue.\n\n'
                          '- If you entered an address, check it\'s '
                          'valid.\n'
                          '- Check your firewall isn\'t blocking '
                          'ports.\n'
                          '- As a last resort, reboot the device.\n\n'
                          'Try again (Yes) or view the debug log (No)?')

        def connect(**kwargs):
            """Get device information.

            :param kwargs: Connection info.
            """
            try:
                self.controller.__init__(**kwargs)
                # Cache the data & return
                self.controller_cache = self.controller.settings
                return self.controller_cache
            except Exception as exc:
                return exc

        def result(result):
            """Check the result before calling the main window.

            :param result: response from the (threaded) method.
            """
            if not result or not isinstance(result, dict):
                # Do we try again or show the result as an error?
                question = self.questionBox('{}'.format(settings_title),
                                            '{}'.format(settings_text))
                if question in ['yes', True, _('yes')]:
                    return self.manual()
                # If we hit here, the user pressed no.
                return self.error(result)
            # Call the main window.
            self.main()

        # First get & merge all user-input sources - inc keywords.
        values = self.values(**kwargs)
        addr = values.get('addr', '')
        tcp = values.get('tcp', 0)
        timeout = values.get('timeout', 0)

        # Check the address.
        if not len(addr) > 0:
            self.errorBox('{}'.format(address_title),
                          '{}'.format(address_text))
            # This is a bit ugly, but at least the user can enter info.
            # And it prevents getting stuck in an interlude.
            return self.manual()
        try:
            # Just remove invalid tcp/timeout.
            if tcp not in range(1, 65535):
                values.pop('tcp')
            if timeout not in range(1, 120):
                values.pop('timeout')
        except KeyError:
            pass

        # Display the main window!
        self.interlude(interlude)
        self.callback(connect, result, **values)

    def main(self):
        """The status window - this is the default."""
        # Clear all widgets & add the toolbar.
        self.reset_window(True)

    def settings(self):
        """The settings window."""
        # Clear all widgets, add toolbar.
        self.reset_window(True)


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


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

Screen cap showing the issue.

peek 2018-01-14 12-57

@jarvisteach

This comment has been minimized.

Owner

jarvisteach commented Jan 14, 2018

.addMenuEdit() only needs to be called once - it is then enabled for all widgets - calling it a second time is causing a problem.

Maybe remove the two calls, and just have one in your main constructor?

@mpmc

This comment has been minimized.

Contributor

mpmc commented Jan 14, 2018

.addMenuEdit() only needs to be called once - it is then enabled for all widgets - calling it a second time is causing a problem.
Maybe remove the two calls, and just have one in your main constructor?

There must be something strange going on, I initially tried that by adding it to the launch method before calling the discover window, but no widgets have the right-click menu at all.

@jarvisteach

This comment has been minimized.

Owner

jarvisteach commented Jan 14, 2018

Same issue as all the others I think - removeAllWidgets() is resetting everything, except the menus, that includes a flag to say right click menus are enabled.

So, it's disables the right-click menu, but leaves it in the widget manager.

I think I'll stop the removeAllWidgets from disabling rightClickMenus, and introduce a function to remove right click menus.

jarvisteach added a commit that referenced this issue Jan 14, 2018

Fix for #347
Investigate #346
fix for #347 - `.removeAllWidgets()` no longer disables rightClickMenu
& new function `.disableMenuEdit()`
More work on #235

@jarvisteach jarvisteach changed the title from [next] appJar.appjar.ItemLookupError: Duplicate key: 'EDIT' already exists to removeAllWidgets breaks rightClickMenu Jan 14, 2018

@jarvisteach jarvisteach added the bug label Jan 14, 2018

@jarvisteach jarvisteach added this to the 0.90 milestone Jan 14, 2018

@jarvisteach

This comment has been minimized.

Owner

jarvisteach commented Jan 14, 2018

OK - removeAllWidgets no longer changes rightClickMenus.
Instead, there's a new function disableMenuEdit()
Calling addMenuEdit() multiple times will no longer complain - if the menu already exists it will simply return (after having set the inUse flag back to True.

@mpmc

This comment has been minimized.

Contributor

mpmc commented Jan 14, 2018

With the latest fix, the menus now show, however I got:

Exception in Tkinter callback
Traceback (most recent call last):
  File "/usr/lib/python3.6/tkinter/__init__.py", line 1702, in __call__
    return self.func(*args)
  File "/home/mark/.local/lib/python3.6/site-packages/appJar/appjar.py", line 9359, in __checkCopyAndPaste
    self.copyAndPaste.setUp(widget)
  File "/home/mark/.local/lib/python3.6/site-packages/appJar/appjar.py", line 12811, in setUp
    if widget.tag_ranges("sel"):
AttributeError: 'Entry' object has no attribute 'tag_ranges'
Exception in Tkinter callback
Traceback (most recent call last):
  File "/usr/lib/python3.6/tkinter/__init__.py", line 1702, in __call__
    return self.func(*args)
  File "/home/mark/.local/lib/python3.6/site-packages/appJar/appjar.py", line 3356, in <lambda>
    return lambda *args: funcName(param)
  File "/home/mark/.local/lib/python3.6/site-packages/appJar/appjar.py", line 9600, in <lambda>
    ('Paste', lambda e: self.__copyAndPasteHelper("Paste"), "V", False),
  File "/home/mark/.local/lib/python3.6/site-packages/appJar/appjar.py", line 9381, in __copyAndPasteHelper
    self.copyAndPaste.setUp(widget)
  File "/home/mark/.local/lib/python3.6/site-packages/appJar/appjar.py", line 12811, in setUp
    if widget.tag_ranges("sel"):
AttributeError: 'Entry' object has no attribute 'tag_ranges'
@jarvisteach

This comment has been minimized.

Owner

jarvisteach commented Jan 15, 2018

Well, tag_ranges is not available on an entry - but the code should only be running that if the widget is a Text - not sure why it would do it for Entries...

@mpmc

This comment has been minimized.

Contributor

mpmc commented Jan 16, 2018

@jarvisteach

I've noticed that if you're fast enough, you're able to call up a right click menu on an entry during it's removal. That may explain that error.

I've also seen it where the right click menu can be delayed and will appear a few seconds afterwards with no widgets visible.

@jarvisteach

This comment has been minimized.

Owner

jarvisteach commented Feb 13, 2018

Closing this, work being done in issue #375

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment