Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

A bunch of fix and improvements #32

Merged
merged 51 commits into from
Jun 27, 2023
Merged

A bunch of fix and improvements #32

merged 51 commits into from
Jun 27, 2023

Conversation

littlewhitecloud
Copy link
Owner

@littlewhitecloud littlewhitecloud commented Jun 11, 2023

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

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("<Key>", check_important, add=True)
txt.bind("<Button-1>", check_important, add=True)

root.mainloop()

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

@littlewhitecloud littlewhitecloud added enhancement New feature or request improve Improvements branch This is a branch or this problem caused by branch labels Jun 11, 2023
@littlewhitecloud littlewhitecloud added this to the v0.0.4 milestone Jun 13, 2023
@littlewhitecloud littlewhitecloud added documentation Improvements or additions to documentation test Test the function labels Jun 13, 2023
@littlewhitecloud
Copy link
Owner Author

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
Copy link
Collaborator

Moosems commented Jun 13, 2023

Just trust me ;).

@littlewhitecloud
Copy link
Owner Author

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
Copy link
Owner Author

@Moosems Can you test the issues now?

@littlewhitecloud
Copy link
Owner Author

littlewhitecloud commented Jun 16, 2023

fixed #27

"""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("<Up>", self.up, add=True)
        self.text.bind("<Down>", self.down, add=True)
        self.text.bind("<Return>", self.loop, add=True)
        for bind_str in ("<Left>", "<BackSpace>"):
            self.text.bind(bind_str, self.left, add=True)
        for bind_str in ("<Return>", "<ButtonRelease-1>"):
            self.text.bind(bind_str, self.updates, add = True)

        self.text.bind("<Control-KeyPress-c>", 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
Copy link
Collaborator

Moosems commented Jun 16, 2023

I'll get a version working by Sunday.

@Moosems
Copy link
Collaborator

Moosems commented Jun 17, 2023

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

@littlewhitecloud
Copy link
Owner Author

littlewhitecloud commented Jun 27, 2023

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

Copy link
Collaborator

@Moosems Moosems left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A really nice update overall but I have left some suggestions on what I think could be slightly improved on.

.editorconfig Outdated Show resolved Hide resolved
README.md Show resolved Hide resolved
tktermwidget/style.py Outdated Show resolved Hide resolved
tktermwidget/style.py Show resolved Hide resolved
tktermwidget/tkterm.py Outdated Show resolved Hide resolved
tktermwidget/tkterm.py Outdated Show resolved Hide resolved
tktermwidget/tkterm.py Outdated Show resolved Hide resolved
tktermwidget/tkterm.py Show resolved Hide resolved
tktermwidget/tkterm.py Outdated Show resolved Hide resolved
tktermwidget/tkterm.py Outdated Show resolved Hide resolved
@Moosems
Copy link
Collaborator

Moosems commented Jun 27, 2023

Emm fixed the issue with before :D!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment