littlewhitecloud / TkTerminal

A terminal emulator widget written in Python using tkinter
MIT License
15 stars 2 forks source link

A bunch of fix and improvements #32

Closed littlewhitecloud closed 1 year ago

littlewhitecloud commented 1 year ago

Flags

_fix a bit of #19 fix #27 fix #29 fix #33 fix #37 fix #38 fix #40 fix #41

Tests

test #22 test #16

Note

@Moosems The pr is yours now (I am buckled up). Remeber to test #22 #16, if there is no problem please add them to the flags list and they will close when the pr closed! Start our trip~

Pin

```python from tkinter import Tk, Text, Event root = Tk() txt = Text(root) txt.pack() txt.tag_configure("important") txt.insert("1.0", "Hello, World!") txt.tag_add("important", "1.0", "1.5") # written on phone so there may be syntax errors def check_important(event: Event) -> None: # Things to check: # Is the text that would be gone to by typing on the same line and to the right of anything important? # Are we selecting stuff? In most terminals, that shouldn't even be allowed. # If it's a click event, is the clicked char important? # If any of these fail, we should return "break" widget = event.widget if widget.tag_ranges("sel"): widget.tag_remove("sel", "1.0", "end") # Determine the action (click or keypress) # Keypress check for a few things: is it backspace (check if previous character is a special one, up or down which inserts that line, return creates a new line and runs the code, and all modified ones like control/command-a and blocks those) # Clicks check for the index of the click and if it is inside one of the special we just bring it to the start of the non special chars important_ranges = widget.tag_ranges("important") if event.type == 4: # The type is a click click_index = widget.index(f"@{event.x},{event.y}) ... return # The type is a keypress ... txt.bind("", check_important, add=True) txt.bind("", check_important, add=True) root.mainloop() ```

Think after merging the pr, we can do some small tweaks and release v0.0.4.

littlewhitecloud commented 1 year ago

Or bind click to the check function, if the cursor’s index isn’t greater than the end-1c, then set the text to the read only state.

Moosems commented 1 year ago

Just trust me ;).

littlewhitecloud commented 1 year ago

Just trust me ;).

Okay, but maybe I will still use the way to improve it but not upload, when you are finished, I will make a cmp.

littlewhitecloud commented 1 year ago

@Moosems Can you test the issues now?

littlewhitecloud commented 1 year ago

fixed #27

```python """Terminal widget for tkinter""" from __future__ import annotations from os import getcwd 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 # Set constants HISTORY_PATH = Path(user_cache_dir("tkterm")) SYSTEM = system() CREATE_NEW_CONSOLE = 0 DIR = "{command}$ " if SYSTEM == "Windows": from subprocess import CREATE_NEW_CONSOLE DIR = "PS {command}>" # Check that the history directory exists if not HISTORY_PATH.exists(): HISTORY_PATH.mkdir(parents=True) # Also create the history file with open(HISTORY_PATH / "history.txt", "w", encoding="utf-8") as f: f.close() # Check that the history file exists if not (HISTORY_PATH / "history.txt").exists(): with open(HISTORY_PATH / "history.txt", "w", encoding="utf-8") as f: f.close() 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 autohide (bool, optional): Whether to autohide the scrollbars. Defaults to True. *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 loop (Event) -> str: Runs the command typed""" def __init__(self, master: Misc, autohide: bool = True, *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 scrollbars scrollbars = Scrollbar if not autohide else AutoHideScrollbar self.xscroll = scrollbars(self, orient="horizontal") self.yscroll = scrollbars(self) self.text = Text( self, *args, background=kwargs.get("background", "#2B2B2B"), insertbackground=kwargs.get("insertbackground", "#DCDCDC"), selectbackground=kwargs.get("selectbackground", "#b4b3b3"), relief=kwargs.get("relief", "flat"), foreground=kwargs.get("foreground", "#cccccc"), xscrollcommand=self.xscroll.set, yscrollcommand=self.yscroll.set, wrap=kwargs.get("wrap", "char"), font=kwargs.get("font", ("Cascadia Code", 9, "normal")), ) 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") self.xscroll.grid(row=1, column=0, sticky="ew") self.yscroll.grid(row=0, column=1, sticky="ns") # Create command prompt self.directory() # Set variables self.longsymbol = "\\" if not SYSTEM == "Windows" else "&&" self.index, self.cursor = 1, self.text.index("insert") self.current_process: Popen | None = None self.latest = self.cursor self.longflag = False self.longcmd = "" # Bind events self.text.bind("", self.up, add=True) self.text.bind("", self.down, add=True) self.text.bind("", self.loop, 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.updates, add = True) self.text.bind("", self.kill, add=True) # Isn't working # History recorder self.history = open(HISTORY_PATH / "history.txt", "r+", encoding="utf-8") self.historys = [i.strip() for i in self.history.readlines() if i.strip()] self.hi = len(self.historys) - 1 def updates(self, _) -> None: """Update cursor""" self.cursor = self.text.index("insert") if self.cursor < self.latest and self.text["state"] != "disabled": # It is lower than the path index self.text["state"] = "disabled" elif self.cursor >= self.latest and self.text["state"] != "normal": self.text["state"] = "normal" def directory(self) -> None: """Insert the directory""" self.text.insert("insert", f"{DIR.format(command=getcwd())}") def newline(self) -> None: """Insert a newline""" self.text.insert("insert", "\n") self.index += 1 def up(self, _: Event) -> str: """Go up in the history""" if self.hi >= 0: self.text.delete(f"{self.index}.0", "end-1c") self.directory() # Insert the command self.text.insert("insert", self.historys[self.hi].strip()) self.hi -= 1 return "break" def down(self, _: Event) -> str: """Go down in the history""" if self.hi < 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.hi].strip()) self.hi += 1 else: # Clear the command self.text.delete(f"{self.index}.0", "end-1c") self.directory() return "break" def left(self, _: Event) -> str: """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('.', maxsplit=1)[0]}.{len(DIR.format(command=getcwd()))}" if insert_index == dir_index: return "break" def kill(self, _: Event) -> str: """Kill the current process""" if self.current_process: self.current_process.kill() self.current_process = None return "break" def loop(self, _: Event) -> str: """Create an input loop""" # Get the command from the text cmd = self.text.get(f"{self.index}.0", "end-1c") # Determine command based on system cmd = cmd.split("$")[-1].strip() if not SYSTEM == "Windows" else cmd.split(">")[-1].strip() if self.longflag: self.longcmd += cmd cmd = self.longcmd self.longcmd = "" self.longflag = False # Check the command if it is a special command if cmd in ["clear", "cls"]: self.text.delete("1.0", "end") self.directory() self.updates(None) self.latest = self.text.index("insert") return "break" elif cmd.endswith(self.longsymbol): self.longcmd += cmd.split(self.longsymbol)[0] self.longflag = True self.newline() return "break" else: pass if cmd: # Record the command if it isn't empty self.history.write(cmd + "\n") self.historys.append(cmd) self.hi = len(self.historys) - 1 else: # Leave the loop self.newline() self.directory() return "break" # Check that the insert position is at the end if self.text.index("insert") != f"{self.index}.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, 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) if line == "\n": self.index += 1 # Update the text and the index self.directory() self.updates(None) self.latest = self.text.index("insert") return "break" # Prevent the default newline character insertion if __name__ == "__main__": from tkinter import Tk # Create root window root = Tk() # Hide root window during initialization root.withdraw() # Set title root.title("Terminal") # Create terminal term = Terminal(root) term.pack(expand=True, fill="both") # Set minimum size and center app # Update widgets so minimum size is accurate root.update_idletasks() # Set the minimum size minimum_width: int = root.winfo_reqwidth() minimum_height: int = root.winfo_reqheight() # Get center of screen based on minimum size x_coords = int(root.winfo_screenwidth() / 2 - minimum_width / 2) y_coords = int(root.wm_maxsize()[1] / 2 - minimum_height / 2) # Place app and make the minimum size the actual minimum size (non-infringable) root.geometry(f"{minimum_width}x{minimum_height}+{x_coords}+{y_coords}") root.wm_minsize(minimum_width, minimum_height) # Show root window root.deiconify() # Start mainloop root.mainloop() ```

I am excited that I improved this with a few lines but still have problems. @Moosems would you like to take a look?

Moosems commented 1 year ago

I'll get a version working by Sunday.

Moosems commented 1 year ago

Hey, something came up today so I might need a few more days. I'm so sorry man.

littlewhitecloud commented 1 year ago

Could you take time test #22 #16 #19? @Moosems

Moosems commented 1 year ago

Will do.

Moosems commented 1 year ago

I'll take over the PR from here if you want.

Moosems commented 1 year ago

22 and #16 both break still and #19 has issues.

Moosems commented 1 year ago

16 still only gets the errors.

littlewhitecloud commented 1 year ago

22 and #16 both break still and #19 has issues.

Can you open a issues to describe the bug?

littlewhitecloud commented 1 year ago

16 still only gets the errors.

Can you give a screenshot or can you fix it by yourself?

littlewhitecloud commented 1 year ago

Here you go, @Moosems

littlewhitecloud commented 1 year ago

@Moosems please take time review it and give some advise and then we acn merge this pr. Thank you very much.

Moosems commented 1 year ago

Emm fixed the issue with before :D!