7
\$\begingroup\$

I made a small POC for a REPL using Tkinter:

# importing modules
import tkinter as tk
import sys
import io

class REPL(tk.Frame):
    def __init__(self, master=None):
        # setting up widgets and needed variables
        super().__init__(master)
        self.master = master
        self.master.title("REPL")
        self.pack(fill=tk.BOTH, expand=True)
        self.create_widgets()
        self.history = []
        self.history_index = 0
        # useful for testing things, can be removed
        self.master.wm_attributes("-topmost", True)

    def create_widgets(self):
        # set up the first text widget that will act as a "prompt", similar to python interpreter
        # for both the first and second text widget, we hide the separation between them, mostly for aesthetic
        self.prompt_widget = tk.Text(self, height=20, width=4, borderwidth=0, highlightthickness=0)
        self.prompt_widget.pack(side=tk.LEFT, fill=tk.Y)
        self.prompt_widget.insert(tk.END, ">>> ")

        # set up the second text widget that will act as the input/output for our repl
        self.text_widget = tk.Text(self, height=20, width=76, borderwidth=0, highlightthickness=0)
        self.text_widget.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        self.text_widget.focus()

        # set up a basic colorscheme, I preferred this one because of eyesight
        self.prompt_widget.configure(bg='black', fg='white')
        self.text_widget.configure(bg='black', fg='white')
        # invert color of blinking cursor to match the background and foreground
        self.text_widget.config(insertbackground="white")

        # adding mousewheel support, so we can scroll on both text widget at the same time, in a synchronous way
        # to match the prompt related to the start of each input in the second widget
        self.text_widget.bind("<MouseWheel>", self.sync_scrolls)
        self.prompt_widget.bind("<MouseWheel>", self.sync_scrolls)

        # Here is the main way to either type a newline, or execute our input
        # Using Shift+Return to execute commands, and Return to just type a normal newline
        self.text_widget.bind("<Shift-Return>", self.execute_command)
        self.text_widget.bind("<Return>", self.newline)

        # goes up and down in history
        self.text_widget.bind('<Up>', self.history_show)
        self.text_widget.bind('<Down>', self.history_show)

        # useful for preventing any "input" if the blinking cursor happen to be on any other places than the latest prompt
        self.text_widget.bind('<Key>', self.is_last_line)

        # prevent clicking on the prompt widget
        self.prompt_widget.bind("<Button-1>", self.do_nothing)

        # copy,paste,cut using Ctrl+c,Ctrl+v,Ctrl+x respectively
        self.text_widget.bind("<Control-c>", self.copy)
        self.text_widget.bind("<Control-v>", self.paste)
        self.text_widget.bind("<Control-x>", self.cut)

    def copy(self, event):
        # copy, which isn't supported by default in this case
        self.text_widget.event_generate("<<Copy>>")
        return "break"

    def paste(self, event):
        # supported by default for some reason, but still set it up anyway
        self.text_widget.event_generate("<<Paste>>")
        return "break"

    def cut(self, event):
        # cut, also already supported by default on Windows 10, but you never know...
        self.text_widget.event_generate("<<Cut>>")
        return "break"

    def sync_scrolls(self, event):
        # sync the two text widget scrolling, doesn't seem to always work
        if event.delta == -120:
            self.prompt_widget.yview_scroll(1, "units")
            self.text_widget.yview_scroll(1, "units")
        elif event.delta == 120:
            self.prompt_widget.yview_scroll(-1, "units")
            self.text_widget.yview_scroll(-1, "units")
        return "break"

    def newline(self, event):
        # make a newline for Return
        self.text_widget.insert(tk.END, "\n")
        return "break"

    def do_nothing(self, event):
        # just useful to make a tkinter component do nothing. Might not always work (eg: for tag related method/function, etc)
        return "break"

    def execute_command(self, event):
        # set up variable for the last line of the prompt widget, last line text/secondary widget, and command
        prompt_last_line = int(self.prompt_widget.index(tk.END).split(".")[0]) - 1
        text_last_line = int(self.text_widget.index(tk.END).split(".")[0]) - 1
        command = self.text_widget.get(f"{prompt_last_line}.0", f"{text_last_line}.end").strip()

        # check if the command variable is empty, which happens if only the Return key or any key mapped to the newline method is used
        if not command:
            self.prompt_widget.insert(tk.END, "\n>>> ")
            self.text_widget.insert(tk.END, "\n")
            return "break"

        # append to history
        self.history.append(command)
        self.history_index = len(self.history)

        # here we execute and show result if there is any
        code = command
        self.text_widget.see(tk.END)
        try:
            # so here we both cache the result of any print() used, and reuse the global namespace and local namespace,
            # so we can execute command "non-continuously", like modern REPL, eg: ipython
            stdout = sys.stdout
            sys.stdout = io.StringIO()
            global_ns = sys.modules['__main__'].__dict__
            local_ns = {}
            exec(code, global_ns, local_ns)
            result = sys.stdout.getvalue()
            sys.stdout = stdout
            global_ns.update(local_ns)
        except Exception as e:
            result = str(e)

        # we insert the result
        self.text_widget.insert(tk.END, f"\n{result}")

        # this part is to "fill" the prompt widget, so we can delimit the starting and end of an input/output on the second widget
        current_line = int(self.text_widget.index(tk.INSERT).split(".")[0])
        prompt_line_index = f"{current_line}.0"
        if current_line > prompt_last_line:
            for i in range(prompt_last_line + 1, current_line + 1):
                self.prompt_widget.insert(f"{i}.0", "\n")
            self.prompt_widget.insert(prompt_line_index, "\n>>> ")

    def is_last_line(self, event):
        # useful method to know if our blinking cursor is currently on current valid latest prompt, which is equal to the latest "\n>>> " in the first widget, and current line in the second widget
        current_line = int(self.text_widget.index(tk.INSERT).split(".")[0])
        prompt_last_line = int(self.prompt_widget.index(tk.END).split(".")[0]) - 1

        # if the blinking cursor is before the latest prompt
        if current_line < prompt_last_line:
            return "break"
        # if blinking cursor is at first line of valid prompt of the second widget/last line of prompt widget, prevent backspace from occuring, since it can circuvent the binding to <Key>
        elif current_line == prompt_last_line and event.keysym == "BackSpace":
            cursor_index = self.text_widget.index(tk.INSERT)
            if cursor_index == f"{current_line}.0":
                return "break"
        # Same as for the backspace. technically this one isn't needed since we allow the cursor to move freely when clicking with the mouse, at least on the second widget
        elif current_line == prompt_last_line and event.keysym == "Left":
            cursor_index = self.text_widget.index(tk.INSERT)
            if cursor_index == f"{current_line}.0":
                return "break"
        else:
            pass

    def history_show(self, event):
        # show history in a very rudimentary way. no deduplication, history replacement on edit, etc.
        prompt_last_line = int(self.prompt_widget.index(tk.END).split(".")[0]) - 1
        current_line = int(self.text_widget.index(tk.INSERT).split(".")[0])
        text_last_line = int(self.text_widget.index(tk.END).split(".")[0]) - 1
        if event.keysym == "Up" and current_line == prompt_last_line:
            if self.history_index > 0:
                self.history_index -= 1
                self.text_widget.delete(f"{prompt_last_line}.0", f"{text_last_line}.end")
                self.text_widget.insert(f"{prompt_last_line}.0", self.history[self.history_index])
            return "break"
        elif event.keysym == "Down" and text_last_line == prompt_last_line:
            if self.history_index < len(self.history) - 1:
                self.history_index += 1
                self.text_widget.delete(f"{prompt_last_line}.0", f"{text_last_line}.end")
                self.text_widget.insert(f"{prompt_last_line}.0", self.history[self.history_index])
            return "break"

root = tk.Tk()
repl = REPL(master=root)
repl.mainloop()

I'm wondering if there is any way to improve this, aside from adding new features (eg: autocomplete, etc) since I want to concentrate on solidifying what I already have first.

I mostly made this as a learning experience, and also because of other project where I wanted to try and use my own REPL, and work my way up (I already know about common libraries for this, such as bpython, ipython, etc to name a few)

Added comments. Here is the keybindings:

  • Ctrl+c -> Copy
  • Ctrl+v -> Paste
  • Ctrl+x -> Cut
  • Arrow keys (Up, Down, Left, Right) -> Move the blinking cursor around the current prompt (not outside of it). Up and Down show history whenever the cursor is either on last or first line.
  • Return/Enter key -> Normal behavior/produce a newline
  • Shift+Return -> Execute command, show output if there is any (need to use print since it cache its output)
  • Up/Down arrow key -> If commands were executed previously, then they will be shown when using those keys.
  • Left Mouse click -> only move the blinking cursor, and support selecting text on the input/output widget. Does nothing if used on the first widget, where the prompts appear.
  • MouseWheel -> (not tested on touchpad, works on external mouse). Support scrolling up and down.

Python version: 3.8.10

Tkinter version: 8.6.9

OS: Windows 10

Repository: https://github.com/secemp9/tkinter-repl

Any feedback is appreciated.

\$\endgroup\$
11
  • \$\begingroup\$ I ran your code, but almost nothing works there, I did not see how you can enter one command and go to the next line, if there is such functionality, please describe how in this round. For example, view history through history_show by key Up. Code improvements are best made when all existing code is working. \$\endgroup\$ Apr 7 at 21:50
  • \$\begingroup\$ Everything works fine on my end. I'll add some instructions and additional comments (and python and tkinter version, since I have a hunch this might be at play here if it doesn't work on your end) @DaniilLoban Thank you for reporting this to me \$\endgroup\$ Apr 7 at 21:52
  • 1
    \$\begingroup\$ Added necessary information. Feel free to give feedback if there is still something missing/not working. Should be fine now @DaniilLoban \$\endgroup\$ Apr 7 at 22:29
  • \$\begingroup\$ After print(tk.TkVersion, sys.version) I hit Shift-RET to see 8.6 and 3.10.8. But then my keystrokes get blackholed. Hitting Ctrl-RET gets me to a point where I can again execute a print statement. This is under MacOS Monterey 12.6.4. It seems a stretch to call this a working REPL. \$\endgroup\$
    – J_H
    Apr 7 at 23:44
  • 1
    \$\begingroup\$ Removing return "break" was helpful, yes. I can type two commands, with Shift-RET, and it works. Still not able to edit, in part due to missing Ctrl-X, which is fine. Multiple attempts to edit + Shift-RET leads to weird multi-line result cell. Squishing width=4 and width=76 cells seems like trouble. I don't know of a better way to ask Tk to .pack cells together. Maybe from Tk's perspective all cells should be similar? Anyway, I won't mess with this further. I imagine it does what you want it to do on win10. \$\endgroup\$
    – J_H
    Apr 8 at 0:27

3 Answers 3

2
\$\begingroup\$

If nothing else, this was a good reminder to me that tkinter is a pain.

Drop comments like # importing modules that make the code less obvious than just reading the code.

Consider moving from is-a (inherit from Frame) to has-a (instantiate Frame) for better encapsulation.

Don't use master as a variable name.

Rather than packing, I find the grid layout to be more maintainable. Don't make fixed-size widgets. I am going to go further and suggest that you don't need separate widgets at all: instead, you can have a single multi-line Text and work with indices. Among other advantages, this will obviate your alignment problems.

Don't assign new variables to self outside of the constructor.

Your current solution has not redirected stderr; it needs to do that.

Your newline and execute key bindings are backwards to what most users will expect. <Return> should execute, and some modifier should insert a new line in the statement.

Make a utility method to decode index strings.

I don't think that it's wise to use __main__ for your namespace. Make one fresh namespace for each of globals and locals, and persist it throughout the program.

exec is not used correctly here. Currently your code will only show stdout and cannot evaluate expressions. This is not what most users of a repl will expect. Instead, the proper usage is a two step compile/exec in mode single.

Don't str(e). You should show a backtrace. I demonstrate how to do this.

Protect the last three statements in a __main__ guard.

Suggested

import tkinter as tk
import traceback
from sys import exc_info
from typing import Optional, Callable, Iterator


class StdoutSys:
    def __init__(self, handler: Callable[[str], None]) -> None:
        import sys
        self.sys = sys
        self.write = handler
        self.old_stdout = sys.stdout
        self.old_stderr = sys.stderr

    def __enter__(self) -> 'StdoutSys':
        self.sys.stdout = self
        self.sys.stderr = self
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        self.sys.stdout = self.old_stdout
        self.sys.stderr = self.old_stderr


class REPL:
    def __init__(self, parent: Optional[tk.Tk] = None) -> None:
        self.text = text = tk.Text(
            parent, background='black', foreground='white',
            insertbackground='white')
        text.grid(row=0, column=0, sticky='NSEW')
        text.focus()
        text.bind('<Return>', self.handle_execute)
        text.bind('<Up>', self.history_show)
        text.bind('<Down>', self.history_show)
        text.bind('<Key>', self.handle_key)

        self.history: list[str] = []
        self.history_index = 0
        self.local_ns = {}
        self.global_ns = {}
        self.prompt_index = '0.0'
        self.new_prompt()

    def coord(self, index: Optional[str] = None) -> Iterator[int]:
        """Convert from tk y.x coordinate string format to integers"""
        if index is None:
            index = self.prompt_index
        for part in self.text.index(index).split('.'):
            yield int(part)

    def new_prompt(self) -> None:
        self.write('>>> ')
        self.move_to_end()
        self.prompt_index = self.text.index(tk.INSERT)

    def move_to_end(self) -> None:
        self.text.mark_set(markName=tk.INSERT, index=tk.END)

    def execute(self, command: str) -> None:
        self.history.append(command)
        self.history_index = len(self.history)

        with StdoutSys(self.write):
            try:
                expr = compile(source=command, filename='<input>', mode='single')
                exec(expr, self.global_ns, self.local_ns)
            except SystemExit:
                # Allow exit() to close the program
                raise
            except BaseException:
                # Skip the first level of the stack (this function)
                exc, value, tb = exc_info()
                self.write(''.join(traceback.format_exception(exc, value, tb.tb_next)))

    def handle_execute(self, event: tk.Event) -> Optional[str]:
        alt_shift_ctrl = 0b1101
        if event.state & alt_shift_ctrl:
            # Do not execute on a modified return; allow editing
            return
        self.write('\n')
        command = self.text.get(self.prompt_index, tk.END).rstrip()
        if command:
            self.execute(command)
        self.new_prompt()
        return 'break'

    def write(self, content: str) -> None:
        self.text.insert(tk.END, content)

    def handle_key(self, event: tk.Event) -> Optional[str]:
        y, x = self.coord('insert')
        if event.keysym == 'BackSpace':
            delta = -1
        else:
            delta = 1
        key_pos = y, x + delta
        prompt_pos = tuple(self.coord())

        if key_pos >= prompt_pos:
            return  # editing in prompt; everything is OK
        # We're before the prompt. Move to the end before editing.
        self.move_to_end()
        if event.keysym == 'BackSpace':
            # Disallow deleting pre-prompt
            return 'break'

    def history_show(self, event: tk.Event) -> Optional[str]:
        """show history in a very rudimentary way. no deduplication, history replacement on edit, etc."""
        prompt_line, _ = self.coord()
        current_line, _ = self.coord(tk.INSERT)
        last_line, _ = self.coord(tk.END)
        last_line -= 1

        if event.keysym == 'Up' and current_line == prompt_line:
            if self.history_index <= 0:
                return
            self.history_index -= 1
        elif event.keysym == 'Down' and current_line == last_line:
            if self.history_index >= len(self.history) - 1:
                return
            self.history_index += 1
        else:
            return

        self.text.delete(self.prompt_index, tk.END)
        self.write(self.history[self.history_index])
        return 'break'


def main() -> None:
    root = tk.Tk()
    root.title('REPL')
    root.rowconfigure(index=0, weight=1)
    root.columnconfigure(index=0, weight=1)
    REPL(parent=root)
    root.mainloop()


if __name__ == '__main__':
    main()

screenshot

\$\endgroup\$
2
  • \$\begingroup\$ great answer, just a question: Would you recommend using ast.literal_eval or exec in this case? I pondered about using the former, but felt that exec was easier to work with. \$\endgroup\$ Apr 9 at 2:01
  • 1
    \$\begingroup\$ exec for these purposes is basically fine. In a security-concerned environment, or with exotic requirements, ast may be needed. \$\endgroup\$
    – Reinderien
    Apr 9 at 2:02
2
\$\begingroup\$

You are great, you did a great job. Well, I also tried :)

The name of the function should answer the question what the function does or what it returns.

The is_last_line should return True or False if you look at the name, like my is_append_to_history function which tries add command to history if command is empty does not happen returns False (logic encapsulation)

The function should do one thing, but do it good — it's easier to find a place where to add new functionality and where to look for the error. Break functions into smaller ones.

In create_widgets i have arranged the widgets and it could be this split into 2 functions for each of the components.

Аdded properties for frequently used calculations:

 self.prompt_last_line = 0
 self.current_line = 0
 self.text_last_line = 0

and the update_lines_info function. This is also useful for displaying an additional information such as the current line number, etc.

I used the with construct (class) to work co stdout. This is very convenient to use when you need to remember to return something after an action.

 if current_line < prompt_last_line:
      return

The condition is not suitable forbids to rise higher through UP, it is not always convenient to rise and fall without UP and DOWN I replaced it by:

if event.keysym == "Up" and self.current_line  == self.prompt_last_line:
   return "break"
elif event.keysym == "Down" and self.current_line + 1 > number_of_lines: 
   return "break"

The newline function did extra work, I deleted it with self.text_widget.bind("<Return>", self.newline)

Added result.strip() to exclude extra line

 >>> print 123
      123

 >>>

now so:

 >>> print 123
      123
 >>>

When removing lines from text through selection and Delete, you need to doing the same with lines in a prompt — sometimes has bugs. For example, I selected the lines with the keys and pressed Delete, it turned out mismatch text and prompt

There is no point in:

 else:
     pass

Code:

import tkinter as tk
import sys
import io

class own_stdout():
    def __init__(self):
        self.stdout = None
    def __enter__(self):
        self.stdout = sys.stdout
        sys.stdout = io.StringIO()
    def __exit__(self, type, value, traceback):
        sys.stdout = self.stdout    

class REPL(tk.Frame):
    def __init__(self, master=None):
        super().__init__(master)
        self.master = master
        self.master.title("REPL")
        self.pack(fill=tk.BOTH, expand=True)
        self.create_widgets()
        self.history = []
        self.history_index = 0
        self.master.wm_attributes("-topmost", True)
        self.prompt_last_line = 0
        self.current_line = 0
        self.text_last_line = 0

    def update_lines_info(self):
        self.prompt_last_line = int(self.prompt_widget.index(tk.END).split(".")[0]) - 1
        self.current_line = int(self.text_widget.index(tk.INSERT).split(".")[0])
        self.text_last_line = int(self.text_widget.index(tk.END).split(".")[0]) - 1

    def create_widgets(self):
        self.prompt_widget = tk.Text(self, height=20, width=4, borderwidth=0, highlightthickness=0)
        self.prompt_widget.pack(side=tk.LEFT, fill=tk.Y)
        self.prompt_widget.insert(tk.END, ">>> ")
        self.prompt_widget.configure(bg='black', fg='white')
        self.prompt_widget.bind("<MouseWheel>", self.sync_scrolls)
        self.prompt_widget.bind("<Button-1>", self.do_nothing)
        self.text_widget = tk.Text(self, height=20, width=76, borderwidth=0, highlightthickness=0)
        self.text_widget.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        self.text_widget.focus()
        self.text_widget.configure(bg='black', fg='white')
        self.text_widget.config(insertbackground="white")
        self.text_widget.bind("<MouseWheel>", self.sync_scrolls)
        self.text_widget.bind("<Shift-Return>", self.execute_command)
        self.text_widget.bind('<Up>', self.history_show)
        self.text_widget.bind('<Down>', self.history_show)
        self.text_widget.bind('<Key>', self.any_key_down)
        self.text_widget.bind("<Control-c>", self.copy)
        self.text_widget.bind("<Control-v>", self.paste)

    def copy(self, event):
        self.text_widget.event_generate("<<Copy>>")

    def paste(self, event):
        self.text_widget.event_generate("<<Paste>>")

    def do_nothing(self, event):
        return "break"  

    ############################################################# 
    # sync_scrolls
    #############################################################
    def sync_scrolls_by_delta(delta):
        self.prompt_widget.yview_scroll(delta, "units")
        self.text_widget.yview_scroll(delta, "units")

    def sync_scrolls(self, event):
        if (abs(event.delta) == 120):    
            sync_scrolls_by_delta(-1 if event.delta < 0 else 1)
    
    ############################################################# 
    # execute_command
    #############################################################
    def run_code(self, code): 
        try:
            global_ns = sys.modules['__main__'].__dict__
            local_ns = {}
            with own_stdout():
                exec(code, global_ns, local_ns)
                result = sys.stdout.getvalue()
            global_ns.update(local_ns)
        except Exception as e:
            result = str(e)
        return result

    def get_code_for_execute(self):
        self.update_lines_info()
        command = self.text_widget.get(
            f"{self.prompt_last_line}.0",
            f"{self.text_last_line}.end"
        ).strip()
        return command

    def insert_result(self, result):    
        self.text_widget.see(tk.END)
        self.text_widget.insert(tk.END, f"\n{result.strip()}")
        self.update_lines_info()
        prompt_line_index = f"{self.current_line}.0"
        if self.current_line >= self.prompt_last_line:
            for i in range(self.prompt_last_line + 1, self.current_line + 1):
                self.prompt_widget.insert(f"{i}.0", "\n")
            self.prompt_widget.insert(prompt_line_index, "\n>>> ")
    
    def is_append_to_history(self, command): 
        print([command])
        if not command:
            self.prompt_widget.insert(tk.END, "\n>>> ")
            self.text_widget.insert(tk.END, "\n")
            return False
        self.history.append(command)
        self.history_index = len(self.history)
        return True

    def execute_command(self, event):
        command = self.get_code_for_execute()
        if not self.is_append_to_history(command): return 
        result = self.run_code(command)
        self.insert_result(result)

    ############################################################# 
    # is_last_line => any_key_down
    #############################################################
    def any_key_down(self, event):
        self.update_lines_info()
        if event.keysym == "Up" and self.current_line  == self.prompt_last_line:
           return "break"
        elif event.keysym == "Down" and self.current_line + 1 > number_of_lines: 
           return "break"
        elif self.current_line == self.prompt_last_line and event.keysym == "BackSpace":
            cursor_index = self.text_widget.index(tk.INSERT)
            if cursor_index == f"{self.current_line}.0":
                return "break"
        elif self.current_line == self.prompt_last_line and event.keysym == "Left":
            cursor_index = self.text_widget.index(tk.INSERT)
            if cursor_index == f"{self.current_line}.0":
                return "break"

    ############################################################# 
    # history_show
    #############################################################
    def history_lookup(self, shift):
        self.update_lines_info()
        self.history_index += shift
        if self.current_line == self.prompt_last_line:
           first = f"{self.prompt_last_line}.0"
           self.text_widget.delete(first, f"{self.text_last_line}.end")
           self.text_widget.insert(first, self.history[self.history_index])

    def history_up(self):
        if self.history_index > 0:
            self.history_lookup(-1)

    def history_down(self):
        if self.history_index < len(self.history) - 1:
            self.history_lookup(1)

    def history_show(self, event):
        if event.keysym == "Up":
            self.history_up()
        elif event.keysym == "Down":
            self.history_down()
        return self.any_key_down(event)

root = tk.Tk()
repl = REPL(master=root)
repl.mainloop()
\$\endgroup\$
1
  • \$\begingroup\$ Thanks for your answer. I noticed that only typing Shift-Return repeatedly propagate the newline from previous prompts (without using any input on the second widget) instead of making empty prompt (the version I did does it, since that's how most REPL does it too, and I thought it made sense). Aside from that, it is a very helpful answer, thank you for your recommendation! \$\endgroup\$ Apr 8 at 11:50
2
\$\begingroup\$

Shrinking Code

Below methods are implemented the same by default, there is no need to overwrite them. Is there? If not, consider to bind to the uppercase letters as well, as tkinter does by default.

    self.text_widget.bind("<Control-c>", self.copy)
    self.text_widget.bind("<Control-v>", self.paste)
    self.text_widget.bind("<Control-x>", self.cut)

Using a dictionary to avoid repetitive hard coded options is always a good way to shrink your lines of code and add readability.

        kw = {
            'borderwidth'           : 0,
            'highlightthickness'    : 0,
            'background'            : '#000000',
            'foreground'            : '#ffffff',
            'insertbackground'      : '#ffffff'
            }

  • exec doesn't need a local namespace and can be None

Readability

While I tended to use methods like create_widgets in the past to sort things out and make them more readable, I choose to avoid it nowadays. the __init__ method is designed to run only once by initiating the instance, bounded methods on the other hand are designed to be reused in the lifetime of your application. Now I tend to use factory patterns that are nested in the __init__ function and these die after initiation. However, before reworking to not understandable factory patterns, you can have the same effect with normal nested functions.


  • Make clear if the methods are intended to be used by developers or not with a single underscore beforehand naming the method.
  • Mark and group your event-methods like _event_newline or _on_return
  • Don't document intention of a single method on different locations of your code
  • Don't exceed the PEP8 recommended character count of 79 on a single line, not even for comments. You want people to read that.
  • Don't check for the same thing twice in the same body, it makes it confusing what your intentions are (also reduced lines of code)

User experience (UX)

  • add a empty string to history_index and make clear it is the end of line here

  • Don't add multi line support to this, it seems non intuitive as REPL to me.

  • jump to the end by pressing Return with:

     self.text_widget.see(tk.END)
     self.text_widget.mark_set('insert',tk.END)
    
  • sync yview position properly with:

def _on_text_yview_configure(self,f1,f2):
    self.prompt_widget.yview_moveto(f1)
def _on_prompt_yview_configure(self,f1,f2):
    self.text_widget.yview_moveto(f1)

def _on_mousewheel(self, event):
    if event.delta == -120:
        event.widget.yview_scroll(1, "units")
    elif event.delta == 120:
        event.widget.yview_scroll(-1, "units")
    return "break"

Bugs

Is it a bug or feature, that after using the Return key, the Up and Down key are used to maneuver over the text field ?


Additional Features

  • implement some sort of help function that the user can read a manual
  • write description to your class that you might want to use in the suggested help feed

Suggested code:

# importing modules
import tkinter as tk
import sys
import io


class REPL(tk.Frame):
    def __init__(self, master=None):
        # setting up widgets and needed variables
        super().__init__(master)
        self.master = master
        self.master.title("REPL")
        self.pack(fill=tk.BOTH, expand=True)
        self.history = ['']
        self.history_index = 0
        # useful for testing things, can be removed
        self.master.wm_attributes("-topmost", True)

        def generate_children():
            #prompt_widget that will act as "prompt"
            self.prompt_widget = tk.Text(self, height=20, width=4)
            #text_widget will act as input/output for our repl
            self.text_widget = tk.Text(self, height=20, width=76)

        def populate_children():
            self.prompt_widget.pack(side=tk.LEFT, fill=tk.Y)
            self.text_widget.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

        def setup():
            self.prompt_widget.insert(tk.END, ">>> ")
            self.text_widget.focus()
            #hide borders of seperate text widgets and colors
            kw = {
                'borderwidth'           : 0,
                'highlightthickness'    : 0,
                'background'            : '#000000',
                'foreground'            : '#ffffff',
                'insertbackground'      : '#ffffff'
                }
            self.prompt_widget.configure(**kw)
            self.text_widget.configure(**kw)
            #add yscrollcommand
            self.text_widget.configure(yscrollcommand=self._on_text_yview_configure)
            self.prompt_widget.configure(yscrollcommand=self._on_prompt_yview_configure)

        def bindings():
            self.text_widget.bind("<MouseWheel>",   self._on_mousewheel)
            self.text_widget.bind("<Return>",       self._on_return)
            self.text_widget.bind('<Up>',           self._on_up_or_down_key)
            self.text_widget.bind('<Down>',         self._on_up_or_down_key)
            self.text_widget.bind('<BackSpace>',    self._on_backspace_or_left)
            self.text_widget.bind('<Left>',         self._on_backspace_or_left)
            self.prompt_widget.bind("<Button-1>",   self._on_button_one)
            self.prompt_widget.bind("<MouseWheel>", self._on_mousewheel)
            
        generate_children(); populate_children(); setup(); bindings()

    def _on_text_yview_configure(self,f1,f2):
        self.prompt_widget.yview_moveto(f1)
    def _on_prompt_yview_configure(self,f1,f2):
        self.text_widget.yview_moveto(f1)

    def _on_mousewheel(self, event):
        if event.delta == -120:
            event.widget.yview_scroll(1, "units")
        elif event.delta == 120:
            event.widget.yview_scroll(-1, "units")
        return "break"

    def _on_button_one(self, event):
        return "break"

    def _on_return(self, event):
        # set up variable for the last line of the prompt widget,
        # last line text/secondary widget, and command
        prompt_last_line = int(self.prompt_widget.index(tk.END).split(".")[0]) - 1
        text_last_line = int(self.text_widget.index(tk.END).split(".")[0]) - 1
        code = self.text_widget.get(f"{prompt_last_line}.0", f"{text_last_line}.end").strip()

        if not code:#add just a new line if empty
            self.prompt_widget.insert(tk.END, "\n>>> ")
            self.text_widget.insert(tk.END, "\n")
            self.text_widget.see(tk.END)
            self.text_widget.mark_set('insert',tk.END)
            return "break"

        self.history.append(code)
        self.history_index = len(self.history)
        self.text_widget.see(tk.END)
        try:
            stdout = sys.stdout
            sys.stdout = io.StringIO()
            global_ns = sys.modules['__main__'].__dict__
            exec(code, global_ns)
            result = sys.stdout.getvalue()
            sys.stdout = stdout
        except Exception as e:
            result = str(e)
        finally:
            self.text_widget.insert(tk.END, f"\n{result}")

        # this part is to "fill" the prompt widget,
        # so we can delimit the starting and end of an
        # input/output on the second widget
        current_line = int(self.text_widget.index(tk.INSERT).split(".")[0])
        prompt_line_index = f"{current_line}.0"
        if current_line > prompt_last_line:
            for i in range(prompt_last_line + 1, current_line + 1):
                self.prompt_widget.insert(f"{i}.0", "\n")
            self.prompt_widget.insert(prompt_line_index, "\n>>> ")
        self.text_widget.insert(tk.END, "\n")
        self.text_widget.see(tk.END)
        self.text_widget.mark_set('insert',tk.END)
        return "break"

    def _on_backspace_or_left(self, event):
        current_line = int(self.text_widget.index(tk.INSERT).split(".")[0])
        cursor_index = self.text_widget.index(tk.INSERT)
        if cursor_index == f"{current_line}.0":
            return "break"

    def _on_up_or_down_key(self, event):
        # show history in a very rudimentary way. no deduplication,
        # history replacement on edit, etc.
        prompt_last_line = int(self.prompt_widget.index(tk.END).split(".")[0]) - 1
        current_line = int(self.text_widget.index(tk.INSERT).split(".")[0])
        text_last_line = int(self.text_widget.index(tk.END).split(".")[0]) - 1
        s_index, e_index = f"{prompt_last_line}.0", f"{text_last_line}.end"
        var = -1 if event.keysym == "Up" else 1
        if current_line == prompt_last_line:
            if var == 1:#DownKey
                if self.history_index < len(self.history) - var:
                    self.history_index += 1
            elif var == -1:#UpKey
                if self.history_index > 0:
                    self.history_index -= 1
            content = self.history[self.history_index]
            self.text_widget.delete(s_index, e_index)
            self.text_widget.insert(s_index, content)
        return "break" #indent this line if move up and down after return is intended
            

root = tk.Tk()
repl = REPL(master=root)
repl.mainloop()
\$\endgroup\$
4
  • \$\begingroup\$ Thank you for this answer. Let me address some good point you mentioned: 1. Shrinking code, I agree that binding to Ctrl+v and Ctrl+x isn't needed, since on Windows10 (at least), it seems to be supported by default on the text widget. However, I noticed that it isn't the case on my end for Ctrl+c for some reason. Using a dictionary is a good idea, and I didn't know the part about exec. 2. I only commented intention to make it easier to read for this question, but you make a fair point for the character count on the comments. Noted. 3. The usage of Up and Down for maneuvering is a feature. \$\endgroup\$ Apr 8 at 11:58
  • \$\begingroup\$ (cont2): The help function is a very good idea. Everything is very helpful overall, thank you. \$\endgroup\$ Apr 8 at 11:59
  • 1
    \$\begingroup\$ @NordineLotfi those are just suggestions, hope you will find some joy and usefulness in it. \$\endgroup\$ Apr 8 at 12:01
  • \$\begingroup\$ yeah, and they are good suggestions, just wanted to explain the thought behind them :) \$\endgroup\$ Apr 8 at 12:11

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.