From 81f456859fc84882cdaa35eee468f044b29a1df0 Mon Sep 17 00:00:00 2001 From: XiaoBaiYun <71159641+littlewhitecloud@users.noreply.github.com> Date: Wed, 12 Jul 2023 17:47:32 +0800 Subject: [PATCH] some small tweaks --- tktermwidget/__init__.py | 37 ++++- tktermwidget/style.py | 10 +- tktermwidget/widgets.py | 335 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 370 insertions(+), 12 deletions(-) create mode 100644 tktermwidget/widgets.py diff --git a/tktermwidget/__init__.py b/tktermwidget/__init__.py index 2e3b19a..f00a52c 100644 --- a/tktermwidget/__init__.py +++ b/tktermwidget/__init__.py @@ -1,3 +1,34 @@ -"""Tktermwidget package""" -from .style import * # noqa: F401 -from .tkterm import Terminal # noqa: F401 +"""__init__ of the tktermwidget package""" +from json import dump +from pathlib import Path + +from platformdirs import user_cache_dir + +from .style import * # noqa: F401, F403 +from .widgets import Terminal # noqa: F401 + +# Get the package path +PACKAGE_PATH = Path(user_cache_dir("tktermwidget")) +# Get the history file +HISTORY_FILE = PACKAGE_PATH / "history.txt" +# Get the json file (style) +JSON_FILE = PACKAGE_PATH / "styles.json" + +if not PACKAGE_PATH.exists(): # Check the "tktermwidget" is exsit + PACKAGE_PATH.mkdir(parents=True) + # Also create the history file + with open(HISTORY_FILE, "w", encoding="utf-8") as f: + f.close() + # Also create the json file + with open(JSON_FILE, "w", encoding="utf-8") as f: + dump("{}", f) + +# Check that the history file exists +if not (HISTORY_FILE).exists(): + with open(HISTORY_FILE, "w", encoding="utf-8") as f: + f.close() + +# Check that the json file exists +if not (JSON_FILE).exists(): + with open(JSON_FILE, "w", encoding="utf-8") as f: + dump("{}", f) diff --git a/tktermwidget/style.py b/tktermwidget/style.py index ab36d49..d916678 100644 --- a/tktermwidget/style.py +++ b/tktermwidget/style.py @@ -56,15 +56,6 @@ "foreground": "#efefef", } -# Check the style file -if not STYLE_PATH.exists(): - STYLE_PATH.mkdir(parents=True) - with open(JSON_FILE, "w", encoding="utf-8") as f: - dump("{}", f) -if not (JSON_FILE).exists(): - with open(JSON_FILE, "w", encoding="utf-8") as f: - dump("{}", f) - # Functions def write_style(**styles) -> None: @@ -280,5 +271,6 @@ def checkhexcolor(self, event: Event, name: str) -> None: CUSTOM: dict[str] = load_style() if __name__ == "__main__": + # An example or a test configstyle = Config(True, basedon=POWERSHELL) configstyle.mainloop() diff --git a/tktermwidget/widgets.py b/tktermwidget/widgets.py new file mode 100644 index 0000000..cc41f2a --- /dev/null +++ b/tktermwidget/widgets.py @@ -0,0 +1,335 @@ +"""Tkinter Terminal widget""" +from __future__ import annotations + +from os import chdir, getcwd, path +from pathlib import Path +from platform import system +from subprocess import PIPE, Popen +from tkinter import Event, Misc, Text +from tkinter.ttk import Frame, Scrollbar + +from platformdirs import user_cache_dir + +if __name__ == "__main__": # For develop + from style import DEFAULT +else: + from .style import DEFAULT + +HISTORY_PATH = Path(user_cache_dir("tktermwidget")) +HISTORY_FILE = HISTORY_PATH / "history.txt" +SYSTEM = system() +CREATE_NEWCONSOLE = 0 +SIGN = "$ " + +if SYSTEM == "Windows": # Check if platform is windows + from subprocess import CREATE_NEW_CONSOLE + + SIGN = ">" + + +class AutoHideScrollbar(Scrollbar): + """Scrollbar that automatically hides when not needed""" + + def __init__(self, master=None, **kwargs): + Scrollbar.__init__(self, master=master, **kwargs) + + def set(self, first: int, last: int): + """Set the Scrollbar""" + if float(first) <= 0.0 and float(last) >= 1.0: + self.grid_remove() + else: + self.grid() + Scrollbar.set(self, first, last) + + +class Terminal(Frame): + """A terminal widget for tkinter applications + + Args: + master (Misc): The parent widget + style (dict, optional): Set the style for the Terminal widget + filehistory (str, optional): Set your own file history instead of the normal + autohide (bool, optional): Whether to autohide the scrollbars. Set true to enable. + *args: Arguments for the text widget + **kwargs: Keyword arguments for the text widget + + Methods for outside use: + None + + Methods for internal use: + up (Event) -> str: Goes up in the history + down (Event) -> str: Goes down in the history + (If the user is at the bottom of the history, it clears the command) + left (Event) -> str: Goes left in the command if the index is greater than the directory + (So the user can't delete the directory or go left of it) + kill (Event) -> str: Kills the current command + check (Event) -> None: Update cursor and check it if is out of the edit range + execute (Event) -> str: Execute the command""" + + def __init__( + self, master: Misc, style: dict = DEFAULT, filehistory: str = None, autohide: bool = False, *args, **kwargs + ): + Frame.__init__(self, master) + + # Set row and column weights + self.rowconfigure(0, weight=1) + self.columnconfigure(0, weight=1) + + # Create text widget and x, y scrollbar + self.style: dict = style + horizontal: bool = False + scrollbar = Scrollbar if not autohide else AutoHideScrollbar + + if kwargs.get("wrap", "char") == "none": + self.xscroll = scrollbar(self, orient="horizontal") + horizontal = True + + self.yscroll = scrollbar(self) + self.text = Text( + self, + *args, + wrap=kwargs.get("wrap", "char"), + yscrollcommand=self.yscroll.set, + relief=kwargs.get("relief", "flat"), + font=kwargs.get("font", ("Cascadia Code", 9, "normal")), + foreground=kwargs.get("foreground", self.style["foreground"]), + background=kwargs.get("background", self.style["background"]), + insertbackground=kwargs.get("insertbackground", self.style["insertbackground"]), + selectbackground=kwargs.get("selectbackground", self.style["selectbackground"]), + selectforeground=kwargs.get("selectforeground", self.style["selectforeground"]), + ) + + if horizontal: + self.text.config(xscrollcommand=self.xscroll.set) + self.xscroll.config(command=self.text.xview) + self.yscroll.config(command=self.text.yview) + + # Grid widgets + self.text.grid(row=0, column=0, sticky="nsew") + if horizontal: + self.xscroll.grid(row=1, column=0, sticky="ew") + self.yscroll.grid(row=0, column=1, sticky="ns") + + # Init command prompt + self.directory() + + # Set constants + self.longsymbol: str = "\\" if not SYSTEM == "Windows" else "&&" + self.filehistory: str = HISTORY_FILE if not filehistory else filehistory + + # Set variables + self.index: int = 1 + self.longcmd: str = "" + self.longflag: bool = False + self.current_process: Popen | None = None + self.cursor: int = self.text.index("insert") + + self.latest: int = self.cursor + + # History recorder + self.history = open( + self.filehistory, + "r+", # Both read and write + encoding="utf-8", + ) + + self.historys = [i.strip() for i in self.history.readlines()] + self.historyindex = len(self.historys) - 1 + + # Bind events + self.text.bind("", self.up, add=True) + self.text.bind("", self.down, add=True) + self.text.bind("", self.execute, add=True) + for bind_str in ("", ""): + self.text.bind(bind_str, self.left, add=True) + for bind_str in ("", "", "", ""): + self.text.bind(bind_str, self.check, add=True) + + self.text.bind("", self.kill, add=True) # Isn't working + + del horizontal + + def check(self, _: Event) -> None: + """Update cursor and check if it is out of the edit range""" + self.cursor = self.text.index("insert") # Update cursor + if float(self.cursor) < float(self.latest): + for bind_str in ("", ""): + self.text.bind(bind_str, lambda _: "break", add=True) + else: + for unbind_str in ("", ""): + self.text.unbind(unbind_str) + + def directory(self) -> None: + """Insert the directory""" + self.text.insert("insert", getcwd() + SIGN) + + def kill(self, _: Event) -> str: + """Kill the current process""" + if self.current_process: + self.current_process.terminate() + self.current_process = None + return "break" + + def execute(self, _: Event) -> str: + """Execute the command""" + # Get the line from the text + cmd = self.text.get(f"{self.index}.0", "end-1c") + # Split the command from the line also strip + cmd = cmd.split(SIGN)[-1].strip() + + # TODO: get rid off all return "break" in if statements + # use the flag leave: bool instead of + + if cmd.endswith(self.longsymbol): + self.longcmd += cmd.split(self.longsymbol)[0] + self.longflag = True + self.newline() + return "break" + + if self.longflag: + cmd = self.longcmd + cmd + self.longcmd = "" + self.longflag = False + + if cmd: # Record the command if it isn't empty + self.history.write(cmd + "\n") + self.historys.append(cmd) + self.historyindex = len(self.historys) - 1 + else: # Leave the loop + self.newline() + self.directory() + self.text.see("end") + return "break" + + # Check the command if it is a special command + if cmd in ["clear", "cls"]: + self.text.delete("1.0", "end") + self.directory() + return "break" + elif cmd == "exit": + self.master.quit() + elif cmd.startswith("cd"): # TAG: is all platform use cd...? + if cmd == "cd..": + chdir(path.abspath(path.join(getcwd(), ".."))) + else: + chdir(cmd.split()[-1]) + self.newline() + self.directory() + return "break" + + # Set the insert position is at the end + self.text.mark_set("insert", f"{self.index}.end") + self.text.see("insert") + + # TODO: Refactor the way we get output from subprocess + # Run the command + self.current_process = Popen( + cmd, + shell=True, + stdout=PIPE, + stderr=PIPE, + stdin=PIPE, + text=True, + cwd=getcwd(), # TODO: use dynamtic path instead (see #35) + creationflags=CREATE_NEW_CONSOLE, + ) + # The following needs to be put in an after so the kill command works + + # Check if the command was successful + returnlines: str = "" + errors: str = "" + returnlines, errors = self.current_process.communicate() + returncode = self.current_process.returncode + self.current_process = None + + if returncode != 0: + returnlines += errors # If the command was unsuccessful, it doesn't give stdout + # TODO: Get the success message from the command (see #16) + + # Output to the text + self.newline() + for line in returnlines: + self.text.insert("insert", line) + + # Update the text and the index + self.index = int(self.text.index("insert").split(".")[0]) + self.update() + print(self.index) + + del returnlines, errors, returncode, cmd + return "break" # Prevent the default newline character insertion + + def newline(self) -> None: + """Insert a newline""" + self.text.insert("insert", "\n") + self.index += 1 + + def update(self) -> str: + """Update the text widget or the command has no output""" + # Insert the directory + self.directory() + # Update cursor and check if it is out of the edit range + self.check(None) + # Update latest index + self.latest = self.text.index("insert") + # Warp to the end + self.text.see("end") + return "break" + + # Keypress + def down(self, _: Event) -> str: + """Go down in the history""" + if self.historyindex < len(self.historys) - 1: + self.text.delete(f"{self.index}.0", "end-1c") + self.directory() + # Insert the command + self.text.insert("insert", self.historys[self.historyindex]) + self.historyindex += 1 + else: + # Clear the command + self.text.delete(f"{self.index}.0", "end-1c") + self.directory() + return "break" + + def left(self, _: Event) -> str | None: + """Go left in the command if the command is greater than the path""" + insert_index = self.text.index("insert") + dir_index = f"{insert_index.split('.')[0]}.{len(getcwd() + SIGN)}" + if insert_index == dir_index: + del insert_index, dir_index + return "break" + + def up(self, _: Event) -> str: + """Go up in the history""" + if self.historyindex >= 0: + self.text.delete(f"{self.index}.0", "end-1c") + self.directory() + # Insert the command + self.text.insert("insert", self.historys[self.historyindex]) + self.historyindex -= 1 + return "break" + + +if __name__ == "__main__": + from tkinter import Tk + + root = Tk() + root.withdraw() + root.title("Terminal") + + term = Terminal(root) + term.pack(expand=True, fill="both") + + root.update_idletasks() + + minimum_width: int = root.winfo_reqwidth() + minimum_height: int = root.winfo_reqheight() + + x_coords = int(root.winfo_screenwidth() / 2 - minimum_width / 2) + y_coords = int(root.wm_maxsize()[1] / 2 - minimum_height / 2) + + root.geometry(f"{minimum_width}x{minimum_height}+{x_coords}+{y_coords}") + root.wm_minsize(minimum_width, minimum_height) + + root.deiconify() + root.mainloop()