diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..58791f4 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,17 @@ +# API Reference + +## Terminals + +::: mono.Terminals + +## Shells + +::: mono.shells + +## Terminal + +::: mono.terminal.Terminal + +## Theme + +::: mono.theme diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..4f948e4 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,107 @@ +## Mono: Embeddable Terminal Emulator + + +!!! Info "New Output Parser" + See [PR #4](https://github.com/tomlin7/mono/pull/4) on new output parser under development + + +**Mono** is a terminal emulator that can be embedded in tkinter applications. See [examples](./examples) to see mono in action. The codebase was extracted from the [**Biscuit project**](https://github.com/billyeatcookies/biscuit) and published as an embeddable widget library. + +- Supports handling **multiple instances** of terminals of different shells running simultaneously. +- Comes as a standalone terminal widget & a **tabbed widget** as well, for handling different terminal instances. +- **Custom terminals** can be made; most shells available on the platform are detected by mono. +- Themes are fully customizable by the user. + +![monopreview](https://github.com/user-attachments/assets/365babe3-0ffd-4095-a8b8-ff98d0e615a7) + +```py title="examples/tabbed.py" hl_lines="2 7 8 9" +import tkinter as tk +from mono import Terminals, get_available_shells, get_shell_from_name + +root = tk.Tk() +root.geometry('800x300') + +terminals = Terminals(root) +terminals.add_default_terminal() +terminals.pack(fill='both', expand=True) + +# A menu for opening terminals +mbtn = tk.Menubutton(root, text="Open Terminal", relief=tk.RAISED) +menu = tk.Menu(mbtn) +for i in get_available_shells(): + menu.add_command(label=i, command=lambda i=i: terminals.open_shell(get_shell_from_name(i))) + +mbtn.config(menu=menu) +mbtn.pack() +root.mainloop() +``` + +`Terminals` is a container for multiple terminals. It provides a simple interface for managing multiple terminals in a tabbed interface. + +All the shells detected for the platform can be accessed with `get_available_shells()`. The `get_shell_from_name()` function returns a shell object from the name of the shell. + +### Custom Terminals + +Following example demonstrates how to create a NodeJS standalone terminal with mono. + +```py title="examples/customshell.py" hl_lines="7 8 9 12 13" +# NOTE: Due to the missing additional ANSI handling, NodeJS shell +# might not work as expected. The issue is being fixed, see pr #4 + +import tkinter as tk +from mono import Terminal + +class NodeJS(Terminal): + name = "NodeJS" + shell = "node" + +root = tk.Tk() +terminal = NodeJS(root) +terminal.start_service() +terminal.pack(fill='both', expand=True) + +root.mainloop() +``` + +### Custom Theming + +Following example implements a custom light theme for mono terminals + +```py title="examples/customtheme.py" hl_lines="4 5 6 7 8 9 21" +import tkinter as tk +from mono import Terminals, Theme + +class Light(Theme): + bg = "#FFFFFF" + fg = "#000000" + abg = "#CCCCCC" + afg = "#000000" + border = "#DDDDDD" + + # further overriding the __init__ will give more control over specific widgets: + # + # def __init__(self, master=None, **kwargs): + # super().__init__(master, **kwargs) + # self.tabs = (self.bg, 'red') + + +root = tk.Tk() +root.geometry("800x300") + +terminals = Terminals(root, theme=Light()) +terminals.pack(fill="both", expand=True) + +terminals.open_python() # open a python console +terminals.open_another_terminal() # open another instance of active + +root.mainloop() +``` + +Further... + +- Shells can be run standalone or in a tabbed interface, see [examples/standalone](./examples/standalone.py). +- Custom terminals can be made by subclassing the `Terminal` class, see [examples/customshell](./examples/customshell.py). +- Custom themes can be passed to the `Terminal`, `Terminals` classes, see [examples/customtheme](./examples/customtheme.py). +- High resolution text rendering can be enabled for windows, see [examples/highres](./examples/highres.py). + +For more examples, see the [examples](./examples) directory. diff --git a/examples/customtheme.py b/examples/customtheme.py index 872b0e5..f2e1e1a 100644 --- a/examples/customtheme.py +++ b/examples/customtheme.py @@ -9,7 +9,8 @@ from mono import Terminals, Theme root = tk.Tk() -root.geometry('800x300') +root.geometry("800x300") + class Light(Theme): bg = "#FFFFFF" @@ -24,9 +25,11 @@ class Light(Theme): # super().__init__(master, **kwargs) # self.tabs = (self.bg, 'red') + terminals = Terminals(root, theme=Light()) -terminals.pack(fill='both', expand=True) +terminals.pack(fill="both", expand=True) -terminals.add_default_terminal() +terminals.open_python() +terminals.open_another_terminal() root.mainloop() diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..3cf0807 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,59 @@ +site_name: Mono +site_description: "Embeddable terminal emulator" +site_url: "https://tomlin7.github.io/mono" +repo_url: "https://github.com/tomlin7/mono" +repo_name: "tomlin7/mono" +copyright: Copyright © 2024 Billy + +theme: + name: "material" + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: pink + accent: pink + toggle: + icon: material/weather-sunny + name: Switch to dark mode + + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: black + accent: pink + toggle: + icon: material/weather-night + name: Switch to light mode + features: + - navigation.top + - toc.follow + - content.code.copy + - content.code.select + +plugins: + - mkdocstrings: + handlers: + python: + options: + heading_level: 3 + - search + - autorefs + +nav: + - Home: index.md + - API Reference: api-reference.md + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/tomlin7/mono + +markdown_extensions: + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - admonition + - pymdownx.details diff --git a/mono/__init__.py b/mono/__init__.py index 173dc89..77639a9 100644 --- a/mono/__init__.py +++ b/mono/__init__.py @@ -1,5 +1,5 @@ __version__ = "0.35.0" -__version_info__ = tuple(map(int, __version__.split('.'))) +__version_info__ = tuple(map(int, __version__.split("."))) import os import platform @@ -15,21 +15,28 @@ def get_home_directory() -> str: - if os.name == 'nt': + if os.name == "nt": return os.path.expandvars("%USERPROFILE%") - if os.name == 'posix': + if os.name == "posix": return os.path.expanduser("~") - return '.' + return "." + class Terminals(tk.Frame): """Mono's tabbed terminal manager - + + This widget is a container for multiple terminal instances. It provides + methods to create, delete, and manage terminal instances. It also provides + methods to run commands in the active terminal and switch between terminals. + Args: master (tk.Tk): Main window. cwd (str): Working directory. theme (Theme): Custom theme instance.""" - - def __init__(self, master, cwd: str=None, theme: Theme=None, *args, **kwargs) -> None: + + def __init__( + self, master, cwd: str = None, theme: Theme = None, *args, **kwargs + ) -> None: super().__init__(master, *args, **kwargs) self.master = master self.base = self @@ -51,11 +58,13 @@ def __init__(self, master, cwd: str=None, theme: Theme=None, *args, **kwargs) -> def add_default_terminal(self, *_) -> Default: """Add a default terminal to the list. Create a tab for it. - + Returns: Default: Default terminal instance.""" - - default_terminal = Default(self, cwd=self.cwd or get_home_directory(), standalone=False) + + default_terminal = Default( + self, cwd=self.cwd or get_home_directory(), standalone=False + ) self.add_terminal(default_terminal) return default_terminal @@ -76,13 +85,13 @@ def add_terminal(self, terminal: Terminal) -> None: self.active_terminals.append(terminal) self.tabs.add_tab(terminal) - + def set_cwd(self, cwd: str) -> None: """Set current working directory for all terminals. - + Args: cwd (str): Directory path.""" - + self.cwd = cwd def open_shell(self, shell: Terminal) -> None: @@ -91,16 +100,22 @@ def open_shell(self, shell: Terminal) -> None: Args: shell (Terminal): Shell type to open (not instance) use add_terminal() to add existing instance.""" - - self.add_terminal(shell(self, cwd=self.cwd or get_home_directory(), standalone=False)) - def open_another_terminal(self, cwd: str=None) -> None: + self.add_terminal( + shell(self, cwd=self.cwd or get_home_directory(), standalone=False) + ) + + def open_another_terminal(self, cwd: str = None) -> None: """Opens another instance of the active terminal. - + Args: cwd (str): Directory path.""" - - self.add_terminal(self.active_terminal_type(self, cwd=cwd or self.cwd or get_home_directory(), standalone=False)) + + self.add_terminal( + self.active_terminal_type( + self, cwd=cwd or self.cwd or get_home_directory(), standalone=False + ) + ) def delete_all_terminals(self, *_) -> None: """Permanently delete all terminal instances.""" @@ -114,7 +129,7 @@ def delete_all_terminals(self, *_) -> None: def delete_terminal(self, terminal: Terminal) -> None: """Permanently delete a terminal instance. - + Args: terminal (Terminal): Terminal instance to delete.""" @@ -123,7 +138,7 @@ def delete_terminal(self, terminal: Terminal) -> None: def delete_active_terminal(self, *_) -> None: """Permanently delete the active terminal.""" - + try: self.tabs.close_active_tab() except IndexError: @@ -131,17 +146,17 @@ def delete_active_terminal(self, *_) -> None: def set_active_terminal(self, terminal: Terminal) -> None: """Switch tabs to the terminal. - + Args: terminal (Terminal): Terminal instance to switch to.""" for tab in self.tabs.tabs: if tab.terminal == terminal: self.tabs.set_active_tab(tab) - + def set_active_terminal_by_name(self, name: str) -> None: """Switch tabs to the terminal by name. - + Args: name (str): Name of the terminal to switch to.""" @@ -155,11 +170,11 @@ def clear_terminal(self, *_) -> None: if active := self.active_terminal: active.clear() - + def run_command(self, command: str) -> None: """Run a command in the active terminal. If there is no active terminal, create a default terminal and run the command. - + Args: command (str): Command to run.""" @@ -169,21 +184,21 @@ def run_command(self, command: str) -> None: # this won't work, TODO: implement a queue for commands else: self.active_terminal.run_command(command) - + @staticmethod def run_in_external_console(self, command: str) -> None: """Run a command in an external console. - + Args: command (str): Command to run.""" match platform.system(): - case 'Windows': - subprocess.Popen(['start', 'cmd', '/K', command], shell=True) - case 'Linux': - subprocess.Popen(['x-terminal-emulator', '-e', command]) - case 'Darwin': - subprocess.Popen(['open', '-a', 'Terminal', command]) + case "Windows": + subprocess.Popen(["start", "cmd", "/K", command], shell=True) + case "Linux": + subprocess.Popen(["x-terminal-emulator", "-e", command]) + case "Darwin": + subprocess.Popen(["open", "-a", "Terminal", command]) case _: print("No terminal emulator detected.") @@ -209,7 +224,7 @@ def open_python(self, *_): @property def active_terminal_type(self) -> Terminal: - """Get the type of the active terminal. If there is no active + """Get the type of the active terminal. If there is no active terminal, return Default type.""" if active := self.active_terminal: @@ -227,7 +242,7 @@ def active_terminal(self) -> Terminal: return self.tabs.active_tab.terminal def refresh(self, *_) -> None: - """Generates <> event that can be bound to hide the terminal + """Generates <> event that can be bound to hide the terminal if there are no active terminals.""" if not self.active_terminals: diff --git a/mono/shells/__init__.py b/mono/shells/__init__.py index 3db1992..54c66db 100644 --- a/mono/shells/__init__.py +++ b/mono/shells/__init__.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import platform +import typing from .bash import Bash from .cmd import CommandPrompt @@ -6,25 +9,57 @@ from .powershell import PowerShell from .python import Python -SHELLS = { - "Default": Default, - "Powershell": PowerShell, - "Python": Python -} +if typing.TYPE_CHECKING: + from mono import Terminal + +SHELLS = {"Default": Default, "Powershell": PowerShell, "Python": Python} if platform.system() == "Windows": SHELLS["Command Prompt"] = CommandPrompt elif platform.system() == "Linux": SHELLS["Bash"] = Bash -def get_available_shells(): + +def get_available_shells() -> dict[str, Terminal]: + """Return a list of available shells.""" + return SHELLS -def get_shell_from_name(name): + +def get_shell_from_name(name: str) -> Terminal | Default: + """Return the shell class from the name. + + If the shell is not found, return the default shell for the platform. + + Args: + name (str): The name of the shell to get. + + Returns: + Terminal: The shell class. + """ + return SHELLS.get(name, Default) -def register_shell(name, shell): + +def register_shell(name: str, shell: Terminal) -> None: + """Register a new shell. + + Args: + name (str): The name of the shell. + shell (Terminal): The shell class. + """ + SHELLS[name] = shell - -def is_shell_registered(name): + + +def is_shell_registered(name: str) -> bool: + """Check if a shell is registered. + + Args: + name (str): The name of the shell. + + Returns: + bool: True if the shell is registered, False otherwise. + """ + return name in SHELLS diff --git a/mono/terminal.py b/mono/terminal.py index 37b5e7a..746aa20 100644 --- a/mono/terminal.py +++ b/mono/terminal.py @@ -3,7 +3,7 @@ from threading import Thread from tkinter import ttk -if os.name == 'nt': +if os.name == "nt": from winpty import PtyProcess as PTY else: from ptyprocess import PtyProcessUnicode as PTY @@ -17,18 +17,21 @@ class Terminal(ttk.Frame): """Terminal abstract class. All shell types should inherit from this class. + The inherited class should implement following attributes: name (str): Name of the terminal. shell (str): command / path to shell executable. - + Args: master (tk.Tk): Main window. cwd (str): Working directory.""" - + name: str shell: str - def __init__(self, master, cwd=".", theme: Theme=None, standalone=True, *args, **kwargs) -> None: + def __init__( + self, master, cwd=".", theme: Theme = None, standalone=True, *args, **kwargs + ) -> None: super().__init__(master, *args, **kwargs) self.master = master self.standalone = standalone @@ -44,19 +47,26 @@ def __init__(self, master, cwd=".", theme: Theme=None, standalone=True, *args, * from .styles import Styles from .theme import Theme + self.theme = theme or Theme() self.style = Styles(self, self.theme) else: self.base = master.base self.theme = self.base.theme - self.text = TerminalText(self, relief=tk.FLAT, padx=10, pady=10, font=("Consolas", 11)) - self.text.config(bg=self.theme.terminal[0], fg=self.theme.terminal[1], insertbackground=self.theme.terminal[1]) + self.text = TerminalText( + self, relief=tk.FLAT, padx=10, pady=10, font=("Consolas", 11) + ) + self.text.config( + bg=self.theme.terminal[0], + fg=self.theme.terminal[1], + insertbackground=self.theme.terminal[1], + ) self.text.grid(row=0, column=0, sticky=tk.NSEW) self.text.bind("", self.enter) self.terminal_scrollbar = Scrollbar(self, style="MonoScrollbar") - self.terminal_scrollbar.grid(row=0, column=1, sticky='NSW') + self.terminal_scrollbar.grid(row=0, column=1, sticky="NSW") self.text.config(yscrollcommand=self.terminal_scrollbar.set) self.terminal_scrollbar.config(command=self.text.yview, orient=tk.VERTICAL) @@ -65,20 +75,21 @@ def __init__(self, master, cwd=".", theme: Theme=None, standalone=True, *args, * self.text.tag_config("command", foreground="yellow") self.bind("", self.stop_service) - + def check_shell(self): """Check if the shell is available in the system path.""" - + import shutil + self.shell = shutil.which(self.shell) return self.shell - + def start_service(self, *_) -> None: """Start the terminal service.""" self.alive = True self.last_command = None - + self.p = PTY.spawn([self.shell]) Thread(target=self._write_loop, daemon=True).start() @@ -97,11 +108,11 @@ def run_command(self, command: str) -> None: def enter(self, *_) -> None: """Enter key event handler for running commands.""" - command = self.text.get('input', 'end') + command = self.text.get("input", "end") self.last_command = command self.text.register_history(command) if command.strip(): - self.text.delete('input', 'end') + self.text.delete("input", "end") self.p.write(command + "\r\n") return "break" @@ -109,31 +120,34 @@ def enter(self, *_) -> None: def _write_loop(self) -> None: while self.alive: if buf := self.p.read(): - p = buf.find('\x1b]0;') - + p = buf.find("\x1b]0;") + if p != -1: buf = buf[:p] - buf = [strip_ansi_escape_sequences(i) for i in replace_newline(buf).splitlines()] - self._insert('\n'.join(buf)) + buf = [ + strip_ansi_escape_sequences(i) + for i in replace_newline(buf).splitlines() + ] + self._insert("\n".join(buf)) - def _insert(self, output: str, tag='') -> None: + def _insert(self, output: str, tag="") -> None: self.text.insert(tk.END, output, tag) - #self.terminal.tag_add("prompt", "insert linestart", "insert") + # self.terminal.tag_add("prompt", "insert linestart", "insert") self.text.see(tk.END) - self.text.mark_set('input', 'insert') - + self.text.mark_set("input", "insert") + def _newline(self): - self._insert('\n') + self._insert("\n") def clear(self) -> None: """Clear the terminal.""" - + self.text.clear() # TODO: Implement a better way to handle key events. def _ctrl_key(self, key: str) -> None: - if key == 'c': - self.run_command('\x03') - + if key == "c": + self.run_command("\x03") + def __str__(self) -> str: return self.name diff --git a/mono/text.py b/mono/text.py index e025a5b..c50be2b 100644 --- a/mono/text.py +++ b/mono/text.py @@ -4,27 +4,28 @@ class TerminalText(tk.Text): """Text widget used to display the terminal output and to get the user input. - Limits the editable area to text after the input mark and prevents deletion + Limits the editable area to text after the input mark and prevents deletion before the input mark. Also, it keeps a history of previously used commands. Args: master (tkinter.Tk, optional): The parent widget. - proxy_enabled (bool, optional): Whether the proxy is enabled. Defaults to True.""" - - def __init__(self, master=None, proxy_enabled: bool=True, **kw) -> None: + proxy_enabled (bool, optional): Whether the proxy is enabled. Defaults to True. + """ + + def __init__(self, master=None, proxy_enabled: bool = True, **kw) -> None: super().__init__(master, **kw) self.master = master - - self.mark_set('input', 'insert') - self.mark_gravity('input', 'left') + + self.mark_set("input", "insert") + self.mark_gravity("input", "left") self.proxy_enabled = proxy_enabled self.config(highlightthickness=0) self._history = [] self._history_level = 0 - self.bind('', self.history_up) - self.bind('', self.history_down) + self.bind("", self.history_up) + self.bind("", self.history_down) self._orig = self._w + "_orig" self.tk.call("rename", self._w, self._orig) @@ -37,9 +38,9 @@ def history_up(self, *_) -> None: return "break" self._history_level = max(self._history_level - 1, 0) - self.mark_set('insert', 'input') - self.delete('input', 'end') - self.insert('input', self._history[self._history_level]) + self.mark_set("insert", "input") + self.delete("input", "end") + self.insert("input", self._history[self._history_level]) return "break" @@ -50,9 +51,9 @@ def history_down(self, *_) -> None: return "break" self._history_level = min(self._history_level + 1, len(self._history) - 1) - self.mark_set('insert', 'input') - self.delete('input', 'end') - self.insert('input', self._history[self._history_level]) + self.mark_set("insert", "input") + self.delete("input", "end") + self.insert("input", self._history[self._history_level]) return "break" @@ -60,7 +61,9 @@ def register_history(self, command: str) -> None: """registers a command in the history""" # don't register empty commands or duplicates - if command.strip() and (not self._history or command.strip() != self._history[-1]): + if command.strip() and ( + not self._history or command.strip() != self._history[-1] + ): self._history.append(command.strip()) self._history_level = len(self._history) @@ -69,9 +72,9 @@ def clear(self, *_) -> None: self.proxy_enabled = False - lastline = self.get('input linestart', 'input') - self.delete('1.0', 'end') - self.insert('end', lastline) + lastline = self.get("input linestart", "input") + self.delete("1.0", "end") + self.insert("end", lastline) self.proxy_enabled = True @@ -84,17 +87,17 @@ def _proxy(self, *args) -> None: try: largs = list(args) - if args[0] == 'insert': - if self.compare('insert', '<', 'input'): - self.mark_set('insert', 'end') + if args[0] == "insert": + if self.compare("insert", "<", "input"): + self.mark_set("insert", "end") elif args[0] == "delete": - if self.compare(largs[1], '<', 'input'): + if self.compare(largs[1], "<", "input"): if len(largs) == 2: return - largs[1] = 'input' - + largs[1] = "input" + result = self.tk.call((self._orig,) + tuple(largs)) return result except: - # most probably some tkinter-unhandled exception + # most probably some tkinter-unhandled exception pass diff --git a/mono/theme.py b/mono/theme.py index 476e67c..9139395 100644 --- a/mono/theme.py +++ b/mono/theme.py @@ -1,4 +1,20 @@ class Theme: + """Color theme for the terminal. + + Attributes: + bg (str): Background color. + fg (str): Foreground color. + abg (str): Active background color. + afg (str): Active foreground color. + border (str): Border color. + tabbar (str): Tab bar background color. This can be modified only after initialization. + tab (tuple): Tab color scheme. This can be modified only after initialization. + tabs (tuple): Tabs color scheme. This can be modified only after initialization. + tab_active (tuple): Active tab color scheme. This can be modified only after initialization. + terminal (tuple): Terminal color scheme. This can be modified only after initialization. + scrollbar (tuple): Scrollbar color scheme. This can be modified only after initialization. + """ + bg = "#181818" fg = "#8B949E" abg = "#2C2D2D" diff --git a/pyproject.toml b/pyproject.toml index 9e9868f..e438d26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,17 @@ [tool.poetry] -name = "mono" -version = "0.35.0" +name = "mono-term" +version = "0.36.0" description = "Embeddable terminal emulator" authors = ["Billy "] license = "MIT" readme = "README.md" -packages = [ - { include = "mono" } -] +packages = [{ include = "mono" }] [tool.poetry.dependencies] python = "^3.10" -pywinpty = {version = "^2.0.13", platform = "win32"} -ptyprocess = {version = "^0.7.0", platform = "linux"} +pywinpty = { version = "^2.0.13", platform = "win32" } +ptyprocess = { version = "^0.7.0", platform = "linux" } [tool.poetry.group.dev.dependencies] pytest = "^8.1.1"