Search code examples
pythonkivyclass-hierarchy

How to access a kivy child widget's function from Python


I have a smallish test case that actually displays two problems.

The overall point of the app (the portion of interest) is to build a console-type display with a ScrollView window above a TextInput widget. For this stripped-down test case, it should echo the entered command "t" and then print out 3 lines of text to the ScrollView, and clear the TextInput. There are other buttons as placeholders to make the hierarchy realistic.

First, I am having a terrible time trying to call a kivy function for a widget down in the hierarchy of my FloatLayout. I have tried screen, app, root, self, <nothing>, and can't seem to find a way to get to the top of the hierarchy to then descend down to the widget of interest. This problem is displayed when the bool GUI_MODE is True. Lines 25 and 38 in test901.py have this problem. Trigger it by typing "t<enter>" in the TextInput block to the right of the ">>>" label.

Secondly, if I set bool GUI_MODE to False, I get a parameter mismatch (too many parameters) for a call that I am not passing any explicit parameters for. Lines 75, 101, and 127 display this problem. This code was lifted from another application, where it worked fine... Trigger it by typing "t<enter>" in the TextInput block to the right of the ">>>" label, with the bool GUI_MODE set to False. (I am nothing if not consistent...)

I would also be interesting to learn how to keep the focus on the TextInput widget after <enter> is pressed.

Here is the Python file (as minimally reproducible as I could make it....)

# test901.py
from kivy.app import App
from kivy.uix.button import Button
from kivy.uix.widget import Widget
from kivy.core.window import Window
from kivy.properties import StringProperty, NumericProperty, ObjectProperty
from kivy.uix.boxlayout import BoxLayout
from kivy.clock import Clock

__version__ = '0.1'

# Set GUI_MODE to True for scrollview output, to False for regular console output
GUI_MODE = True


def common_print(prompt):
    if GUI_MODE:
        print(prompt)  # For debug only
        # May need a "for row in prompt: print_gui_output(row)" here...
        ########################################################################################
        # HERE IS PROBLEM 1a, I can't figure out the addressing to get to this function....    #
        # game_screen, app, root, screen are all unrecognised                                  #
        # Error visible if GUI_MODE = True, and "t<enter>" is typed on the TextInput GUI >>>   #
        ########################################################################################
        game_screen.print_gui_output(prompt)

    else:
        print(prompt)


def common_input(prompt):
    if GUI_MODE:
        ########################################################################################
        # HERE IS PROBLEM 1b, I can't figure out the addressing to get to this function....    #
        # game_screen, app, root, screen are all unrecognised                                  #
        # Error visible if GUI_MODE = True, and "t <enter>" is typed on the TextInput GUI      #
        ########################################################################################
        my_input = game_screen.gui_input(prompt)
    else:
        my_input = input(prompt)
    return my_input


class ScrollWindow(BoxLayout):
    line_count = 0
    io_history = ObjectProperty(None)

    def gui_input(self, command):
        print("step1")
        # Add output to history
        self.line_count += 1
        if command[-1] == 13:  # got here via the enter key rather than the button
            command = command[0:-2]  # delete the "enter" keystroke
        row = HistoryOutput()
        row.line_num = str(self.line_count)
        # Here is the place we add text to the scrolling text window
        row.output_text = ' ' + command
        self.io_history.add_widget(row)
        # Here is where we prepare the input for parsing, and set the quik_param if provided
        cmd = command.lower().strip()
        cmd_list2 = cmd.split()
        quik_param = ""
        if len(cmd_list2) > 1:
            cmd = cmd_list2[0]
            quik_param = cmd_list2[1]
            # Here we interpret the command and execute it
        parse_cmd(cmd, quik_param)
        # Work-around for displayed row height issues
        print("step2")
        ########################################################################################
        # HERE IS PROBLEM 2a, I can't figure why this complains of parameter mismatch          #
        # I am not passing any explicit parameters to recalc_height, just the implicit self    #
        # Error visible if GUI_MODE = False, and "t <enter>" is typed on the TextInput GUI     #
        ########################################################################################
        Clock.schedule_once(self.recalc_height)
        print("step3")

        return command

    def print_gui_output(self, rows):
        print("Step4")
        # Add output to history
        for my_row in rows:
            self.line_count += 1
            if my_row[-1] == 13:  # got here via the enter key rather than the button
                my_row = my_row[0:-2]  # delete the "enter" keystroke
            row = HistoryOutput()
            row.line_num = str(self.line_count)
            # Here is the place we add text to the scrolling text window
            row.output_text = ' ' + my_row
            self.io_history.add_widget(row)

            # Work-around for displayed row height issues
            print("Step5")

            ########################################################################################
            # HERE IS PROBLEM 2b, I can't figure why this complains of parameter mismatch          #
            # I am not passing any explicit parameters to recalc_height, just the implicit self    #
            # Error visible if GUI_MODE = False, and "t <enter>" is typed on the TextInput GUI     #
            ########################################################################################
            Clock.schedule_once(self.recalc_height)
            print("Step6")

    def recalc_height(self):
        """ A method to add and remove a widget from the io_history to force
            the recalculation of its height. Without this, the scrollview will
            not work correctly.
        """
        work_around = Widget()
        self.io_history.add_widget(work_around)
        self.io_history.remove_widget(work_around)


class HistoryOutput(BoxLayout):
    def collapse_row(self, app, lbl):
        if lbl.shorten:
            lbl.shorten = False
        else:
            lbl.shorten = True

            print("Step7")
        ########################################################################################
        # HERE IS PROBLEM 2c, I can't figure why this complains of parameter mismatch          #
        # I am not passing any explicit parameters to recalc_height, just the implicit self    #
        # Error visible if GUI_MODE = False, and "t <enter>" is typed on the TextInput GUI     #
        ########################################################################################
        Clock.schedule_once(app.root.recalc_height)


class ScrollBox(Button):
    pass

    index = NumericProperty(0)
    text_name = StringProperty("")


class GameScreen(Widget):
    pass


class test901App(App):
    def build(self):
        return GameScreen()


Window.clearcolor = (1, 1, 1, 1)
Window.size = (1700, 936)


# from dateutil.parser import parse


def parse_cmd(cmd, quik_param):
    if cmd == "t":
        common_print("This is line 1\nThis is line 2\nThis is line 3")
    else:
        common_print("Unrecognized command, try again")


def get_string(prompt):
    # my_string = "" + common_input(prompt)
    my_string = common_input(prompt)
    if my_string == "q":
        # print("*** Quick Exit from get_string ***")
        raise KeyboardInterrupt
    return my_string


if __name__ == '__main__':
    game_screen = test901App().run()
    print("Stage2")


# Command line parser
while True:
    try:
        print("Stage_Get_Command")
        cmd2 = get_string(
            "\n\nEnter a command ")
        cmd2 = cmd2.lower().strip()
        cmd_list = cmd2.split()
        quik_param2 = ""
        if len(cmd_list) > 1:
            cmd2 = cmd_list[0]
            quik_param2 = cmd_list[1]
            # print("Long Command")
        parse_cmd(cmd2, quik_param2)
    except KeyboardInterrupt:
        continue

And here is the kivy file, test901.kv

#:kivy 2.3.0

<ScrollBox>:
    size_hint_y: None
    height: 16

    canvas:
    Button:
        size_hint_y: None
        height: 16
        halign: 'left'
        font_size: 13
        font_name: 'RobotoMono-Regular'
        text_size: self.width, self.height
        background_normal: str(False)
        #on_press: self.parent.parent.parent.parent.make_selection(self.parent.index)
        text:"Hi World"

<GameScreen>:
    id: game_screen
    canvas:
        Color:
            rgba: 0, 0, 1, 1  # Blue (For the border)
        Rectangle:
            pos: root.x, root.y
            size: root.width, root.height
        Color:
            rgba: 1, 1, 1, 1  # White
        Rectangle:
            pos: root.x + 5, root.y + 5
            size: root.width - 10, root.height - 10

    FloatLayout:
        pos: 0,0
        size: root.width, root.height
        id: float_layout

        # Floating array of Top Buttons, most of which are MultiSelectSpinners

        Button:
            text: 'Hi World'
            pos_hint: {'x': .006, 'y': .89 }
            size_hint: 0.045, .1
            font_size: 22
            bold: True
            background_normal: ''
            background_color: 0.2, 0.7, .2, 1 # Green
            halign: 'center'

        ScrollWindow:
            id: scroll_window
            pos_hint: {'x': .005, 'y': .15 }
            size_hint: 0.988, .7

        # Grid of bottom buttons
        GridLayout:
            cols: 2
            pos_hint: {'x': .05, 'y': .01}
            size_hint: None, None
            size: root.width * .9, root.height * .1
            color: 1, 1, 0, 1
                # rgba: 0, 1, 0, 1 # Black text

            Button:
                text: 'Selection'

            Button:
                text: 'Add'


#:set padding_base 2
#:set sm_button_width 36

<HistoryOutput@BoxLayout>
    height: output_label.height
    orientation: 'horizontal'
    size_hint: 1, None
    line_num: "0"
    output_text: "???"
    ToggleButton:
        height: self.texture_size[1] + sp(padding_base)
        id: output_label
        size_hint: 1, None
        font_name: 'RobotoMono-Regular'
        font_size: 14
        text: root.output_text
        text_size: self.size[0], None
        background_color: {'normal': (0,0,0,1), 'down': (1,1,0,1)} [self.state]  # Black / Green
        background_normal: "1"

<ScrollWindow>:
    io_history: io_history
    orientation: 'vertical'
    padding: sp(padding_base), sp(padding_base)
    # Scrolling text window
    ScrollView:
        BoxLayout:
            height: sum([c.height for c in self.children]) + (2 * sp(padding_base))
            id: io_history
            orientation: 'vertical'
            size_hint: 1, None

    # Console Input Line
    BoxLayout:
        height: sp(32)
        orientation: 'horizontal'
        size_hint: 1, None
        Label:
            id: prompt_label
            size_hint: None, 1
            color: 0, 0, 0, 1  # Black
            text: ">>>"
            width: sp(sm_button_width)
        TextInput:
            id: main_input
            multiline: False
            on_text_validate: root.gui_input(main_input.text); main_input.text=""

        Button:
            on_press: root.gui_input(main_input.text)
            size_hint: None, 1
            text: "Enter"
            width: self.texture_size[0] + (8 * sp(padding_base))

With the bool GUI_MODE set to True, and the command "t<enter>" entered into the TextInput widget, I get the following error trace:

C:\Users\rick\AppData\Local\Programs\Python\Python310\python.exe C:/Users/rick/PycharmProjects/cruise2cruise/test901.py
[INFO   ] [Logger      ] Record log in C:\Users\rick\.kivy\logs\kivy_24-05-02_34.txt
[INFO   ] [deps        ] Successfully imported "kivy_deps.angle" 0.4.0
[INFO   ] [deps        ] Successfully imported "kivy_deps.glew" 0.3.1
[INFO   ] [deps        ] Successfully imported "kivy_deps.sdl2" 0.7.0
[INFO   ] [Kivy        ] v2.3.0
[INFO   ] [Kivy        ] Installed at "C:\Users\rick\AppData\Local\Programs\Python\Python310\lib\site-packages\kivy\__init__.py"
[INFO   ] [Python      ] v3.10.5 (tags/v3.10.5:f377153, Jun  6 2022, 16:14:13) [MSC v.1929 64 bit (AMD64)]
[INFO   ] [Python      ] Interpreter at "C:\Users\rick\AppData\Local\Programs\Python\Python310\python.exe"
[INFO   ] [Logger      ] Purge log fired. Processing...
[INFO   ] [Logger      ] Skipped file C:\Users\rick\.kivy\logs\kivy_24-04-27_47.txt, PermissionError(13, 'The process cannot access the file because it is being used by another process')
[INFO   ] [Logger      ] Purge finished!
[INFO   ] [Factory     ] 195 symbols loaded
[INFO   ] [Image       ] Providers: img_tex, img_dds, img_sdl2 (img_pil, img_ffpyplayer ignored)
[INFO   ] [Text        ] Provider: sdl2
[INFO   ] [Window      ] Provider: sdl2
[INFO   ] [GL          ] Using the "OpenGL" graphics system
[INFO   ] [GL          ] GLEW initialization succeeded
[INFO   ] [GL          ] Backend used <glew>
[INFO   ] [GL          ] OpenGL version <b'4.6.0 Compatibility Profile Context 22.20.27.09.230330'>
[INFO   ] [GL          ] OpenGL vendor <b'ATI Technologies Inc.'>
[INFO   ] [GL          ] OpenGL renderer <b'AMD Radeon RX 5700 XT'>
[INFO   ] [GL          ] OpenGL parsed version: 4, 6
[INFO   ] [GL          ] Shading version <b'4.60'>
[INFO   ] [GL          ] Texture max size <16384>
[INFO   ] [GL          ] Texture max units <32>
[INFO   ] [Window      ] auto add sdl2 input provider
[INFO   ] [Window      ] virtual keyboard not allowed, single mode, not docked
[WARNING] [Factory     ] Ignored class "HistoryOutput" re-declaration. Current -  module: None, cls: <class '__main__.HistoryOutput'>, baseclass: None, filename: None. Ignored -  module: None, cls: None, baseclass: BoxLayout, filename: C:\Users\rick\PycharmProjects\cruise2cruise\test901.kv.
[INFO   ] [Base        ] Start application main loop
[INFO   ] [GL          ] NPOT texture support is available
[INFO   ] [Base        ] Leaving application in progress...
 Traceback (most recent call last):
   File "C:\Users\rick\PycharmProjects\cruise2cruise\test901.py", line 170, in <module>
     game_screen = test901App().run()
   File "C:\Users\rick\AppData\Local\Programs\Python\Python310\lib\site-packages\kivy\app.py", line 956, in run
     runTouchApp()
   File "C:\Users\rick\AppData\Local\Programs\Python\Python310\lib\site-packages\kivy\base.py", line 574, in runTouchApp
     EventLoop.mainloop()
   File "C:\Users\rick\AppData\Local\Programs\Python\Python310\lib\site-packages\kivy\base.py", line 341, in mainloop
     self.window.mainloop()
   File "C:\Users\rick\AppData\Local\Programs\Python\Python310\lib\site-packages\kivy\core\window\window_sdl2.py", line 776, in mainloop
     if self.dispatch('on_key_down', key,
   File "kivy\\_event.pyx", line 727, in kivy._event.EventDispatcher.dispatch
   File "kivy\\_event.pyx", line 1307, in kivy._event.EventObservers.dispatch
   File "kivy\\_event.pyx", line 1231, in kivy._event.EventObservers._dispatch
   File "C:\Users\rick\AppData\Local\Programs\Python\Python310\lib\site-packages\kivy\core\window\__init__.py", line 163, in _on_window_key_down
     return self.dispatch('on_key_down', keycode, text, modifiers)
   File "kivy\\_event.pyx", line 727, in kivy._event.EventDispatcher.dispatch
   File "kivy\\_event.pyx", line 1307, in kivy._event.EventObservers.dispatch
   File "kivy\\_event.pyx", line 1231, in kivy._event.EventObservers._dispatch
   File "C:\Users\rick\AppData\Local\Programs\Python\Python310\lib\site-packages\kivy\uix\textinput.py", line 2984, in keyboard_on_key_down
     self._key_down(key)
   File "C:\Users\rick\AppData\Local\Programs\Python\Python310\lib\site-packages\kivy\uix\textinput.py", line 2890, in _key_down
     self.dispatch('on_text_validate')
   File "kivy\\_event.pyx", line 727, in kivy._event.EventDispatcher.dispatch
   File "kivy\\_event.pyx", line 1307, in kivy._event.EventObservers.dispatch
   File "kivy\\_event.pyx", line 1191, in kivy._event.EventObservers._dispatch
   File "C:\Users\rick\AppData\Local\Programs\Python\Python310\lib\site-packages\kivy\lang\builder.py", line 60, in custom_callback
     exec(__kvlang__.co_value, idmap)
   File "C:\Users\rick\PycharmProjects\cruise2cruise\test901.kv", line 117, in <module>
     on_text_validate: root.gui_input(main_input.text); main_input.text=""
   File "C:\Users\rick\PycharmProjects\cruise2cruise\test901.py", line 67, in gui_input
     parse_cmd(cmd, quik_param)
   File "C:\Users\rick\PycharmProjects\cruise2cruise\test901.py", line 155, in parse_cmd
     common_print("This is line 1\nThis is line 2\nThis is line 3")
   File "C:\Users\rick\PycharmProjects\cruise2cruise\test901.py", line 25, in common_print
     game_screen.print_gui_output(prompt)
 NameError: name 'game_screen' is not defined. Did you mean: 'GameScreen'?
step1
This is line 1
This is line 2
This is line 3

Process finished with exit code 1

With the bool GUI_MODE set to False, and the command "t<enter>" entered into the TextInput widget, I get the following error trace:

 

C:\Users\rick\AppData\Local\Programs\Python\Python310\python.exe C:/Users/rick/PycharmProjects/cruise2cruise/test901.py
[INFO   ] [Logger      ] Record log in C:\Users\rick\.kivy\logs\kivy_24-05-02_35.txt
[INFO   ] [deps        ] Successfully imported "kivy_deps.angle" 0.4.0
[INFO   ] [deps        ] Successfully imported "kivy_deps.glew" 0.3.1
[INFO   ] [deps        ] Successfully imported "kivy_deps.sdl2" 0.7.0
[INFO   ] [Kivy        ] v2.3.0
[INFO   ] [Kivy        ] Installed at "C:\Users\rick\AppData\Local\Programs\Python\Python310\lib\site-packages\kivy\__init__.py"
[INFO   ] [Python      ] v3.10.5 (tags/v3.10.5:f377153, Jun  6 2022, 16:14:13) [MSC v.1929 64 bit (AMD64)]
[INFO   ] [Python      ] Interpreter at "C:\Users\rick\AppData\Local\Programs\Python\Python310\python.exe"
[INFO   ] [Logger      ] Purge log fired. Processing...
[INFO   ] [Logger      ] Skipped file C:\Users\rick\.kivy\logs\kivy_24-04-27_47.txt, PermissionError(13, 'The process cannot access the file because it is being used by another process')
[INFO   ] [Logger      ] Purge finished!
[INFO   ] [Factory     ] 195 symbols loaded
[INFO   ] [Image       ] Providers: img_tex, img_dds, img_sdl2 (img_pil, img_ffpyplayer ignored)
[INFO   ] [Text        ] Provider: sdl2
[INFO   ] [Window      ] Provider: sdl2
[INFO   ] [GL          ] Using the "OpenGL" graphics system
[INFO   ] [GL          ] GLEW initialization succeeded
[INFO   ] [GL          ] Backend used <glew>
[INFO   ] [GL          ] OpenGL version <b'4.6.0 Compatibility Profile Context 22.20.27.09.230330'>
[INFO   ] [GL          ] OpenGL vendor <b'ATI Technologies Inc.'>
[INFO   ] [GL          ] OpenGL renderer <b'AMD Radeon RX 5700 XT'>
[INFO   ] [GL          ] OpenGL parsed version: 4, 6
[INFO   ] [GL          ] Shading version <b'4.60'>
[INFO   ] [GL          ] Texture max size <16384>
[INFO   ] [GL          ] Texture max units <32>
[INFO   ] [Window      ] auto add sdl2 input provider
[INFO   ] [Window      ] virtual keyboard not allowed, single mode, not docked
[WARNING] [Factory     ] Ignored class "HistoryOutput" re-declaration. Current -  module: None, cls: <class '__main__.HistoryOutput'>, baseclass: None, filename: None. Ignored -  module: None, cls: None, baseclass: BoxLayout, filename: C:\Users\rick\PycharmProjects\cruise2cruise\test901.kv.
[INFO   ] [Base        ] Start application main loop
[INFO   ] [GL          ] NPOT texture support is available
step1
This is line 1
This is line 2
This is line 3
step2
step3
[INFO   ] [Base        ] Leaving application in progress...
 Traceback (most recent call last):
   File "C:\Users\rick\PycharmProjects\cruise2cruise\test901.py", line 170, in <module>
     game_screen = test901App().run()
   File "C:\Users\rick\AppData\Local\Programs\Python\Python310\lib\site-packages\kivy\app.py", line 956, in run
     runTouchApp()
   File "C:\Users\rick\AppData\Local\Programs\Python\Python310\lib\site-packages\kivy\base.py", line 574, in runTouchApp
     EventLoop.mainloop()
   File "C:\Users\rick\AppData\Local\Programs\Python\Python310\lib\site-packages\kivy\base.py", line 339, in mainloop
     self.idle()
   File "C:\Users\rick\AppData\Local\Programs\Python\Python310\lib\site-packages\kivy\base.py", line 379, in idle
     Clock.tick()
   File "C:\Users\rick\AppData\Local\Programs\Python\Python310\lib\site-packages\kivy\clock.py", line 733, in tick
     self.post_idle(ts, self.idle())
   File "C:\Users\rick\AppData\Local\Programs\Python\Python310\lib\site-packages\kivy\clock.py", line 776, in post_idle
     self._process_events()
   File "kivy\\_clock.pyx", line 620, in kivy._clock.CyClockBase._process_events
   File "kivy\\_clock.pyx", line 653, in kivy._clock.CyClockBase._process_events
   File "kivy\\_clock.pyx", line 649, in kivy._clock.CyClockBase._process_events
   File "kivy\\_clock.pyx", line 218, in kivy._clock.ClockEvent.tick
 TypeError: ScrollWindow.recalc_height() takes 1 positional argument but 2 were given

Process finished with exit code 1


Solution

  • You can access the print_gui_output() method by replacing:

    game_screen.print_gui_output(prompt)
    

    with:

    App.get_running_app().root.ids.scroll_window.print_gui_output(prompt)
    

    The App.get_running_app() gets the current running App, then root gets the root widget of the App (which is a GameScreen instance returned by your build() method), then ids.scroll_window get the instance of ScrollWindow that contains the print_gui_output() method.

    Note that ids specified in the kv are only added to the root of the rule in which they appear. So the game_screen id would only appear in the GameScreen instance. In fact, since that id would only reference itself, that id is not included in the GameScreen ids.

    The error concerning the recalc_height() method is caused by the Clock.schedule_once() adding a dt argument when it calls recalc_height(). You can handle that by just adding *args to the signature of recalc_height():

    def recalc_height(self, *args):