2

I'm trying to make use of this excellent answer by Bryan Oakley, but to no avail (https://stackoverflow.com/a/4140988/5060127)...

I would like to use the same method to verify Spinbox values. I have defined from_ and to values for spinboxes, but user can still type most anything in them... it should be validated that only values within the from_-to range are possible to be inputted by the user, and only integers at that.

Here's the code that shows how far I've got...

try:
    from Tkinter import *
except ImportError:
    from tkinter import *

class GUI:
    def __init__(self):
        # root window of the whole program
        self.root = Tk()
        self.root.title('ImageSound')

        # registering validation command
        vldt_ifnum_cmd = (self.root.register(self.ValidateIfNum),'%s', '%S')

        # creating a spinbox
        harm_count = Spinbox(self.root, from_=1, to=128, width=5, justify='right', validate='all', validatecommand=vldt_ifnum_cmd)
        harm_count.delete(0,'end')
        harm_count.insert(0,8)
        harm_count.pack(padx=10, pady=10)

    def ValidateIfNum(self, s, S):
        # disallow anything but numbers
        valid = S.isdigit()
        if not valid:
            self.root.bell()
        return valid

if __name__ == '__main__':
    mainwindow = GUI()
    mainloop()
Community
  • 1
  • 1
Mario Krušelj
  • 653
  • 1
  • 6
  • 20
  • But your `ValidateIfNum` method *doesn't* apply that range validation. – jonrsharpe Jul 01 '15 at 10:33
  • The problem is that `Spinbox` widget doesn't provide `validate` and `validatecommand` parameters. They are accepted, but (my guess is that) they are ignored. – Tupteq Jul 01 '15 at 10:43
  • But I set up validatecommand exactly how Bryan laid it out - using .register... See the link. I'd like to figure out why .isdigit isn't doing what it's supposed to do, BEFORE I go on and do range validation... Bryan's example does work when I swap `valid = (S.lower() == S)` with `valid = S.isdigit()`... so why doesn't it work over here? – Mario Krušelj Jul 01 '15 at 10:57

3 Answers3

2

I think I found the problem. Validator function is called initially with S='' and your condition S.isdigit() returns False and function is not called anymore. But after I updated condition to valid = S == '' or S.isdigit() it started to work as expected.

Of course you'll probably want some more sophisticated condition (e.g. checking if value is within range), but it looks like empty string has to pass (at least initial) validation.

Tupteq
  • 2,986
  • 1
  • 21
  • 30
  • Wow, it was that simple! Thank you! And yes, you're correct, isdigit returns False on an empty string, and it seems that's what was tripping it off. It works now. Thank you! Let's see if I'm gonna be able to do range validation somehow... If a number outside of range is typed in, it should revert to last valid number. – Mario Krušelj Jul 01 '15 at 11:18
  • I'm glad I could help, please consider marking this as solved. Range checking may be more tricky because in validator you get only old value of field and new value may depend on caret position, selected text etc. – Tupteq Jul 01 '15 at 11:25
  • Well, %s substitution includes the whole text entry, so I'm pretty sure I'm going to have to use that one somehow in my comparisons, along with .delete/.insert to overwrite the out of range user input... If I get stuck somewhere again, I will edit my original post, so please keep an eye on this one if you can :) Marked your answer as solved, in the meantime. It really DID help - I was losing my whole day yesterday trying to figure this out (I'm very new to Python and tkinter)! – Mario Krušelj Jul 01 '15 at 11:27
  • Yes, `%S` can be the empty string. Quote: "The text string being inserted/deleted, if any. Otherwise it is an empty string." Source: https://wiki.tcl-lang.org/page/tkinter.Spinbox – josch Jun 10 '19 at 22:13
1

I have done it! Both integer-only input and range-checking that takes widget's from_ and to values into account is working! It perhaps looks a bit hacky, but it's working! Here's the code for anyone interested:

try:
    from Tkinter import *
except ImportError:
    from tkinter import *

class GUI:
    def __init__(self):
        # root window of the whole program
        self.root = Tk()
        self.root.title('ImageSound')

        # registering validation command
        vldt_ifnum_cmd = (self.root.register(self.ValidateIfNum),'%P', '%S', '%W')

        # creating a spinbox
        harm_count = Spinbox(self.root, from_=1, to=128, width=5, justify='right', validate='all', validatecommand=vldt_ifnum_cmd)
        harm_count.insert(0,8)
        harm_count.delete(1,'end')
        harm_count.pack(padx=10, pady=10)

    def ValidateIfNum(self, user_input, new_value, widget_name):
        # disallow anything but numbers in the input
        valid = new_value == '' or new_value.isdigit()
        # now that we've ensured the input is only integers, range checking!
        if valid:
            # get minimum and maximum values of the widget to be validated
            minval = int(self.root.nametowidget(widget_name).config('from')[4])
            maxval = int(self.root.nametowidget(widget_name).config('to')[4])
            # check if it's in range
            if int(user_input) not in range (minval, maxval):
                valid = False
        if not valid:
            self.root.bell()
        return valid

if __name__ == '__main__':
    mainwindow = GUI()
    mainloop()

One thing that I noticed isn't quite working is if you select the whole text in the spinbox, and paste something wrong, like text. That breaks validation completely. Ugh.

Mario Krušelj
  • 653
  • 1
  • 6
  • 20
  • I ran into the same problem. The trouble is if you attempt to overwrite selected text, it is interpreted as two independent events: delete and then insert. There does not appear to be any way to know if the delete event will then be followed by insert. Really what is need is a new event: overwrite selected text. – Todd Nov 22 '15 at 16:36
0

I've came up with a solution that works for any Entry widget and thus SpinBox's as well. It uses validatecommand to ensure only the correct values are entered. A blank entry is temporarily validate but on FocusOut it changes back to the last valid value.

intvalidate.py

import tkinter as tk


def int_validate(entry_widget, limits=(None, None)):
    """
    Validates an entry_widget so that only integers within a specified range may be entered

    :param entry_widget: The tkinter.Entry widget to validate
    :param limits: The limits of the integer. It is given as a (min, max) tuple

    :return:       None
    """
    num_str = entry_widget.get()
    current = None if (not _is_int(num_str)) else int(num_str)
    check = _NumberCheck(entry_widget, limits[0], limits[1], current=current)
    entry_widget.config(validate='all')
    entry_widget.config(validatecommand=check.vcmd)
    entry_widget.bind('<FocusOut>', lambda event: _validate(entry_widget, check))
    _validate(entry_widget, check)


def _is_int(num_str):
    """
    Returns whether or not a given string is an integer

    :param num_str: The string to test

    :return: Whether or not the string is an integer
    """
    try:
        int(num_str)
        return True
    except ValueError:
        return False


def _validate(entry, num_check):
    """
    Validates an entry so if there is invalid text in it it will be replaced by the last valid text

    :param entry: The entry widget
    :param num_check: The _NumberCheck instance that keeps track of the last valid number

    :return:    None
    """
    if not _is_int(entry.get()):
        entry.delete(0, tk.END)
        entry.insert(0, str(num_check.last_valid))


class _NumberCheck:
    """
    Class used for validating entry widgets, self.vcmd is provided as the validatecommand
    """

    def __init__(self, parent, min_, max_, current):
        self.parent = parent
        self.low = min_
        self.high = max_
        self.vcmd = parent.register(self.in_integer_range), '%d', '%P'

        if _NumberCheck.in_range(0, min_, max_):
            self.last_valid = 0
        else:
            self.last_valid = min_
        if current is not None:
            self.last_valid = current

    def in_integer_range(self, type_, after_text):
        """
        Validates an entry to make sure the correct text is being inputted
        :param type_:        0 for deletion, 1 for insertion, -1 for focus in
        :param after_text:   The text that the entry will display if validated
        :return:
        """

        if type_ == '-1':
            if _is_int(after_text):
                self.last_valid = int(after_text)

        # Delete Action, always okay, if valid number save it
        elif type_ == '0':
            try:
                num = int(after_text)
                self.last_valid = num
            except ValueError:
                pass
            return True

        # Insert Action, okay based on ranges, if valid save num
        elif type_ == '1':
            try:
                num = int(after_text)
            except ValueError:
                if self.can_be_negative() and after_text == '-':
                    return True
                return False
            if self.is_valid_range(num):
                self.last_valid = num
                return True
            return False
        return False

    def can_be_negative(self):
        """
        Tests whether this given entry widget can have a negative number

        :return: Whether or not the entry can have a negative number
        """
        return (self.low is None) or (self.low < 0)

    def is_valid_range(self, num):
        """
        Tests whether the given number is valid for this entry widgets range

        :param num: The number to range test

        :return: Whether or not the number is in range
        """
        return _NumberCheck.in_range(num, self.low, self.high)

    @staticmethod
    def in_range(num, low, high):
        """
        Tests whether or not a number is within a specified range inclusive

        :param num: The number to test if its in the range
        :param low: The minimum of the range
        :param high: The maximum of the range

        :return: Whether or not the number is in the range
        """
        if (low is not None) and (num < low):
            return False
        if (high is not None) and (num > high):
            return False
        return True

It's used as such

import tkinter as tk
from tkinter import ttk

from intvalidate import int_validate

if __name__ == '__main__':
    root = tk.Tk()
    var = tk.DoubleVar()
    widget = ttk.Spinbox(root, textvariable=var, justify=tk.CENTER, from_=0, to_=10)
    widget.pack(padx=10, pady=10)
    int_validate(widget, limits=(0, 10))
    root.mainloop()
TheLoneMilkMan
  • 233
  • 1
  • 7