From e40f6013c32c59d1f5d70f9502d3102a7576b547 Mon Sep 17 00:00:00 2001 From: Dogeek Date: Sun, 12 Jul 2020 13:26:59 +0200 Subject: [PATCH 1/3] [widget] Shell widget to display a terminal like interface --- docs/source/authors.rst | 14 +- docs/source/ttkwidgets/ttkwidgets.rst | 4 +- .../ttkwidgets/ttkwidgets.Shell.rst | 10 + examples/example_shell.py | 21 ++ tests/test_shell.py | 16 ++ ttkwidgets/__init__.py | 1 + ttkwidgets/shell.py | 205 ++++++++++++++++++ 7 files changed, 263 insertions(+), 8 deletions(-) create mode 100644 docs/source/ttkwidgets/ttkwidgets/ttkwidgets.Shell.rst create mode 100644 examples/example_shell.py create mode 100644 tests/test_shell.py create mode 100644 ttkwidgets/shell.py diff --git a/docs/source/authors.rst b/docs/source/authors.rst index 49f8755d..ed38947c 100644 --- a/docs/source/authors.rst +++ b/docs/source/authors.rst @@ -14,19 +14,19 @@ List of all the authors of widgets in this repository. Please note that this lis * :class:`~ttkwidgets.frames.Tooltip` * :class:`~ttkwidgets.ItemsCanvas` * :class:`~ttkwidgets.TimeLine` - + - The Python Team * :class:`~ttkwidgets.Calendar`, found `here `_ - + - Mitja Martini * :class:`~ttkwidgets.autocomplete.AutocompleteEntry`, found `here `_ - + - Russell Adams * :class:`~ttkwidgets.autocomplete.AutocompleteCombobox`, found `here `_ - + - `Juliette Monsel `_ * :class:`~ttkwidgets.CheckboxTreeview` @@ -35,8 +35,10 @@ List of all the authors of widgets in this repository. Please note that this lis * :class:`~ttkwidgets.AutoHideScrollbar` based on an idea by `Fredrik Lundh `_ * All color widgets: :func:`~ttkwidgets.color.askcolor`, :class:`~ttkwidgets.color.ColorPicker`, :class:`~ttkwidgets.color.GradientBar` and :class:`~ttkwidgets.color.ColorSquare`, :class:`~ttkwidgets.color.LimitVar`, :class:`~ttkwidgets.color.Spinbox`, :class:`~ttkwidgets.color.AlphaBar` and supporting functions in :file:`functions.py`. * :class:`~ttkwidgets.autocomplete.AutocompleteEntryListbox` - + +- `Dogeek `_ + * :class:`~ttkwidgets.Shell` + - Multiple authors: * :class:`~ttkwidgets.ScaleEntry` (RedFantom and Juliette Monsel) - diff --git a/docs/source/ttkwidgets/ttkwidgets.rst b/docs/source/ttkwidgets/ttkwidgets.rst index eb2f17f4..604d958b 100644 --- a/docs/source/ttkwidgets/ttkwidgets.rst +++ b/docs/source/ttkwidgets/ttkwidgets.rst @@ -10,7 +10,7 @@ ttkwidgets .. autosummary:: :nosignatures: :toctree: ttkwidgets - + AutoHideScrollbar Calendar CheckboxTreeview @@ -22,4 +22,4 @@ ttkwidgets Table TickScale TimeLine - + Shell diff --git a/docs/source/ttkwidgets/ttkwidgets/ttkwidgets.Shell.rst b/docs/source/ttkwidgets/ttkwidgets/ttkwidgets.Shell.rst new file mode 100644 index 00000000..0e84850a --- /dev/null +++ b/docs/source/ttkwidgets/ttkwidgets/ttkwidgets.Shell.rst @@ -0,0 +1,10 @@ +Table +===== + +.. currentmodule:: ttkwidgets + +.. autoclass:: Shell + :show-inheritance: + :members: + + .. automethod:: __init__ diff --git a/examples/example_shell.py b/examples/example_shell.py new file mode 100644 index 00000000..89693c71 --- /dev/null +++ b/examples/example_shell.py @@ -0,0 +1,21 @@ +import os +import tkinter as tk + +from ttkwidgets import Shell + + +def onreturn(buffer): + import shlex + lexed = shlex.split(buffer, posix=True) + shell.print(lexed) + +def contractuser(path): + expand = os.path.expanduser('~') + return path.replace(expand, '~') + +root = tk.Tk() +root.title(os.getcwd()) +shell = Shell(root, prefix=contractuser(os.getcwd()) + ' ') +shell.add_command('onreturn', onreturn) +shell.pack() +root.mainloop() \ No newline at end of file diff --git a/tests/test_shell.py b/tests/test_shell.py new file mode 100644 index 00000000..906277db --- /dev/null +++ b/tests/test_shell.py @@ -0,0 +1,16 @@ +# Copyright (c) Dogeek 2020 +# For license see LICENSE +from ttkwidgets import Shell +from tests import BaseWidgetTest + + +class TestShell(BaseWidgetTest): + def test_shell_init(self): + shell = Shell(self.window) + shell.pack() + self.window.update() + shell.destroy() + + def test_shell_forcefocus(self): + shell = Shell(self.window, force_focus=True) + shell.after(1000, lambda: self.assertIsNotNone(shell.focus_get())) diff --git a/ttkwidgets/__init__.py b/ttkwidgets/__init__.py index 9788fb40..a6c743af 100644 --- a/ttkwidgets/__init__.py +++ b/ttkwidgets/__init__.py @@ -11,3 +11,4 @@ from ttkwidgets.timeline import TimeLine from ttkwidgets.tickscale import TickScale from ttkwidgets.table import Table +from ttkwidgets.shell import Shell diff --git a/ttkwidgets/shell.py b/ttkwidgets/shell.py new file mode 100644 index 00000000..8147d5b7 --- /dev/null +++ b/ttkwidgets/shell.py @@ -0,0 +1,205 @@ +import tkinter as tk +import tkinter.font as tkfont +from collections import defaultdict + + +class Shell(tk.Canvas): + def __init__(self, master, textvariable=None, prefix='', force_focus=True, + font=None, **kwargs): + """ + :param master: parent widget + :type master: tkinter.Widget + :param textvariable: A tkinter variable that holds the current text buffer + :type textvariable: tkinter.StringVar or None + :param prefix: A prefix to show on every input line + :type prefix: str + :param force_focus: whether or not the shell should take the focus. + :type force_focus: bool + :param font: Font to use for the terminal + :type font: tkinter.font.Font, tuple, or None + """ + for key, value in { + 'background': 'black', + 'takefocus': True, + }.items(): + if key not in kwargs: + kwargs[key] = value + super().__init__(master, **kwargs) + if kwargs['takefocus'] and force_focus: + self.focus_force() + + self.bind('', self.on_key_press) + self.bind('', self.on_configure) + self.textvariable = textvariable if isinstance(textvariable, tk.StringVar) else tk.StringVar() + self.prefix = prefix + self.font = font or ('Terminal', 10) + self.line_pos = (5, 5) + self.texts = [] + self.last_text = None + self.cursor = None + self.blink = True + self.buffer = prefix + self.text_update() + self.commands = defaultdict(list) + self.cursor_blink() + + def on_key_press(self, event): + if event.keysym == 'Return' and len(self.buffer) > len(self.prefix): + self.texts.append(self.last_text) + span = self.text_line_span() + self.last_text = None + self.line_pos = (5, self.line_pos[1] + 15 * span) + self.call_command('onreturn', self.buffer[len(self.prefix):]) + self.buffer = self.prefix + self.text_update() + return + + if event.keysym == 'Tab': + self.call_command('ontab', self) + return + + if event.keysym == 'BackSpace' and len(self.buffer) > len(self.prefix): + self.buffer = self.buffer[:-1] + self.text_update() + return + + if event.char.strip() or event.keysym == 'space': + self.buffer += event.char + self.text_update() + return + + def on_configure(self, event): + padding = 4 + width = self.master.winfo_width() - padding + height = self.master.winfo_height() - padding + self.config(width=width, height=height) + for t in self.texts: + self.itemconfig(t, width=width) + self.itemconfig(self.last_text, width=width) + + def text_update(self): + """ + Updates the text on the screen. + """ + self.textvariable.set(self.buffer) + if self.last_text: + self.delete(self.last_text) + kwargs = { + 'anchor': tk.NW, + 'fill': 'white', + 'text': self.buffer, + 'width': self['width'], + 'font': self.font, + } + self.last_text = self.create_text(*self.line_pos, **kwargs) + return + + def cursor_blink(self): + if self.cursor: + self.delete(self.cursor) + self.cursor = None + if self.last_text is not None and self.blink: + pos = self.cursor_pos + font = self.itemcget(self.last_text, 'font').split() + width, height = self.max_char_width, int(font[-1]) + pos = pos + (pos[0] + width, pos[1] + height) + self.cursor = self.create_rectangle(*pos, fill='white') + self.blink = not self.blink + self.after(1000, self.cursor_blink) + + @property + def max_char_width(self): + """ + Gets the width of a W character + + :returns: width of W with the font the shell uses + :rtype: int + """ + font = tkfont.Font(self, font=self.itemcget(self.last_text, 'font')) + return font.measure('W') + + @property + def cursor_pos(self): + text = self.itemcget(self.last_text, 'text') + font = tkfont.Font(self, font=self.itemcget(self.last_text, 'font')) + text_len = font.measure(text) + width = self.max_char_width + span = self.text_line_span() + x = text_len % int(self['width']) + width + self.line_pos[0] + y = self.line_pos[1] + 15 * (span - 1) + return x, y + + def text_line_span(self): + """ + Gets the number of lines to display the current text + + :returns: number of lines + :rtype: int + """ + text = self.itemcget(self.last_text, 'text') + font = tkfont.Font(self, font=self.itemcget(self.last_text, 'font')) + text_len = font.measure(text) + n_lines = text_len // int(self['width']) + 1 + return n_lines + + def call_command(self, command, *args): + """ + Calls command callbacks + + :param command: Command name to call + :type command: str + """ + commands = self.commands.get(command, []) + if callable(commands): + return commands(*args) + + for callback in commands: + callback(*args) + + def add_command(self, command, *callbacks): + """ + Adds a command callback + + :param command: command name to bind the callback to + :type command: str + :param *callbacks: list of callbacks to bind to the command + :type *callbacks: list[callable] + """ + assert all(callable(callback) for callback in callbacks), f'Callback should be a function' + self.commands[command].extend(callbacks) + + def print(self, message): + """ + Prints a message on the screen + + :param message: message to write + :type message: str + """ + self.buffer = message + self.text_update() + self.texts.append(self.last_text) + span = self.text_line_span() + self.last_text = None + self.line_pos = (5, self.line_pos[1] + 15 * span) + self.buffer = self.prefix + self.text_update() + + +if __name__ == '__main__': + import os + + def onreturn(buffer): + import shlex + lexed = shlex.split(buffer, posix=True) + print(lexed) + + def contractuser(path): + expand = os.path.expanduser('~') + return path.replace(expand, '~') + + root = tk.Tk() + root.title(os.getcwd()) + shell = Shell(root, prefix=contractuser(os.getcwd()) + ' ') + shell.add_command('onreturn', onreturn) + shell.pack() + root.mainloop() From aad25bdb335e9b166724b2756de44609e868f2f8 Mon Sep 17 00:00:00 2001 From: Dogeek Date: Sun, 12 Jul 2020 13:29:08 +0200 Subject: [PATCH 2/3] Update AUTHORS.md --- AUTHORS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AUTHORS.md b/AUTHORS.md index 5e60a2d5..f871f656 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -24,5 +24,7 @@ This file contains a list of all the authors of widgets in this repository. Plea * `AutoHideScrollbar` based on an idea by [Fredrik Lundh](effbot.org/zone/tkinter-autoscrollbar.htm) * All color widgets: `askcolor`, `ColorPicker`, `GradientBar` and `ColorSquare`, `LimitVar`, `Spinbox`, `AlphaBar` and supporting functions in `functions.py`. * `AutocompleteEntryListbox` +- [Dogeek](https://www.github.com/Dogeek) + * `Shell` - Multiple authors: * `ScaleEntry` (RedFantom and Juliette Monsel) From 316376da91d634fe9d44a6c680066c5f0b9aac08 Mon Sep 17 00:00:00 2001 From: Dogeek Date: Sat, 26 Sep 2020 12:56:51 +0200 Subject: [PATCH 3/3] Add command history, fixed reviewed problems, improved print --- ttkwidgets/shell.py | 92 ++++++++++++++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 31 deletions(-) diff --git a/ttkwidgets/shell.py b/ttkwidgets/shell.py index 8147d5b7..6634c135 100644 --- a/ttkwidgets/shell.py +++ b/ttkwidgets/shell.py @@ -1,11 +1,11 @@ import tkinter as tk import tkinter.font as tkfont -from collections import defaultdict +from collections import defaultdict, deque class Shell(tk.Canvas): def __init__(self, master, textvariable=None, prefix='', force_focus=True, - font=None, **kwargs): + font=None, history_size=1_000, **kwargs): """ :param master: parent widget :type master: tkinter.Widget @@ -36,17 +36,20 @@ def __init__(self, master, textvariable=None, prefix='', force_focus=True, self.line_pos = (5, 5) self.texts = [] self.last_text = None - self.cursor = None - self.blink = True + self._cursor = None + self._blink = True + self._command_history = deque(maxlen=history_size) + self._history_index = None self.buffer = prefix self.text_update() self.commands = defaultdict(list) - self.cursor_blink() + self._cursor_blink() def on_key_press(self, event): if event.keysym == 'Return' and len(self.buffer) > len(self.prefix): self.texts.append(self.last_text) - span = self.text_line_span() + self._command_history.append(self.buffer) + span = self.text_line_span self.last_text = None self.line_pos = (5, self.line_pos[1] + 15 * span) self.call_command('onreturn', self.buffer[len(self.prefix):]) @@ -68,6 +71,21 @@ def on_key_press(self, event): self.text_update() return + if event.keysym == 'Up': + if self._history_index is None: + self._history_index = 0 + self._history_index = min( + self._history_index + 1, len(self._command_history), + ) + self.recall_command() + return + + if event.keysym == 'Down': + self._history_index -= 1 + if self._history_index < 0: + self._history_index = None + self.recall_command() + def on_configure(self, event): padding = 4 width = self.master.winfo_width() - padding @@ -77,10 +95,18 @@ def on_configure(self, event): self.itemconfig(t, width=width) self.itemconfig(self.last_text, width=width) + def recall_command(self): + if self._history_index is None: + self.buffer = self.prefix + self.text_update() + return + + text = self._command_history[-self._history_index] + self.buffer = text + self.text_update() + def text_update(self): - """ - Updates the text on the screen. - """ + """Updates the text on the screen.""" self.textvariable.set(self.buffer) if self.last_text: self.delete(self.last_text) @@ -92,23 +118,22 @@ def text_update(self): 'font': self.font, } self.last_text = self.create_text(*self.line_pos, **kwargs) - return - - def cursor_blink(self): - if self.cursor: - self.delete(self.cursor) - self.cursor = None - if self.last_text is not None and self.blink: - pos = self.cursor_pos + + def _cursor_blink(self): + if self._cursor: + self.delete(self._cursor) + self._cursor = None + if self.last_text is not None and self._blink: + pos = self._cursor_pos font = self.itemcget(self.last_text, 'font').split() - width, height = self.max_char_width, int(font[-1]) + width, height = self._max_char_width, int(font[-1]) pos = pos + (pos[0] + width, pos[1] + height) - self.cursor = self.create_rectangle(*pos, fill='white') - self.blink = not self.blink - self.after(1000, self.cursor_blink) + self._cursor = self.create_rectangle(*pos, fill='white') + self._blink = not self._blink + self.after(1000, self._cursor_blink) @property - def max_char_width(self): + def _max_char_width(self): """ Gets the width of a W character @@ -119,16 +144,17 @@ def max_char_width(self): return font.measure('W') @property - def cursor_pos(self): + def _cursor_pos(self): text = self.itemcget(self.last_text, 'text') font = tkfont.Font(self, font=self.itemcget(self.last_text, 'font')) text_len = font.measure(text) - width = self.max_char_width - span = self.text_line_span() + width = self._max_char_width + span = self.text_line_span x = text_len % int(self['width']) + width + self.line_pos[0] y = self.line_pos[1] + 15 * (span - 1) return x, y + @property def text_line_span(self): """ Gets the number of lines to display the current text @@ -149,7 +175,11 @@ def call_command(self, command, *args): :param command: Command name to call :type command: str """ - commands = self.commands.get(command, []) + def default_command(*a): + nonlocal command + self.print('Unknown command %s.' % command) + + commands = self.commands.get(command, default_command) if callable(commands): return commands(*args) @@ -168,17 +198,17 @@ def add_command(self, command, *callbacks): assert all(callable(callback) for callback in callbacks), f'Callback should be a function' self.commands[command].extend(callbacks) - def print(self, message): + def print(self, *messages, end=' '): """ Prints a message on the screen - :param message: message to write - :type message: str + :param *messages: messages to write + :type messages: str """ - self.buffer = message + self.buffer = end.join(str(m) for m in messages) self.text_update() self.texts.append(self.last_text) - span = self.text_line_span() + span = self.text_line_span self.last_text = None self.line_pos = (5, self.line_pos[1] + 15 * span) self.buffer = self.prefix