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.
3 Answers 3
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()
-
\$\begingroup\$ great answer, just a question: Would you recommend using
ast.literal_eval
orexec
in this case? I pondered about using the former, but felt thatexec
was easier to work with. \$\endgroup\$Nordine Lotfi– Nordine Lotfi2023年04月09日 02:01:08 +00:00Commented Apr 9, 2023 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– Reinderien2023年04月09日 02:02:20 +00:00Commented Apr 9, 2023 at 2:02
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()
-
\$\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\$Nordine Lotfi– Nordine Lotfi2023年04月08日 11:50:23 +00:00Commented Apr 8, 2023 at 11:50
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 beNone
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 hereDon'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()
-
\$\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\$Nordine Lotfi– Nordine Lotfi2023年04月08日 11:58:06 +00:00Commented Apr 8, 2023 at 11:58 -
\$\begingroup\$ (cont2): The help function is a very good idea. Everything is very helpful overall, thank you. \$\endgroup\$Nordine Lotfi– Nordine Lotfi2023年04月08日 11:59:03 +00:00Commented Apr 8, 2023 at 11:59
-
1\$\begingroup\$ @NordineLotfi those are just suggestions, hope you will find some joy and usefulness in it. \$\endgroup\$Thingamabobs– Thingamabobs2023年04月08日 12:01:21 +00:00Commented Apr 8, 2023 at 12:01
-
\$\begingroup\$ yeah, and they are good suggestions, just wanted to explain the thought behind them :) \$\endgroup\$Nordine Lotfi– Nordine Lotfi2023年04月08日 12:11:57 +00:00Commented Apr 8, 2023 at 12:11
history_show
by keyUp
. Code improvements are best made when all existing code is working. \$\endgroup\$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\$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\$