Skip to content

Merge shell into master to add Shell widget #63

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

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AUTHORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
14 changes: 8 additions & 6 deletions docs/source/authors.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <http://svn.python.org/projects/sandbox/trunk/ttk-gsoc/samples/ttkcalendar.py>`_

- Mitja Martini

* :class:`~ttkwidgets.autocomplete.AutocompleteEntry`, found `here <https://mail.python.org/pipermail/tkinter-discuss/2012-January/003041.html>`_

- Russell Adams

* :class:`~ttkwidgets.autocomplete.AutocompleteCombobox`, found `here <https://mail.python.org/pipermail/tkinter-discuss/2012-January/003041.html>`_

- `Juliette Monsel <https://www.github.com/j4321>`_

* :class:`~ttkwidgets.CheckboxTreeview`
Expand All @@ -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 <effbot.org/zone/tkinter-autoscrollbar.htm>`_
* 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 <https://www.github.com/Dogeek>`_
* :class:`~ttkwidgets.Shell`

- Multiple authors:

* :class:`~ttkwidgets.ScaleEntry` (RedFantom and Juliette Monsel)

4 changes: 2 additions & 2 deletions docs/source/ttkwidgets/ttkwidgets.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ ttkwidgets
.. autosummary::
:nosignatures:
:toctree: ttkwidgets

AutoHideScrollbar
Calendar
CheckboxTreeview
Expand All @@ -22,4 +22,4 @@ ttkwidgets
Table
TickScale
TimeLine

Shell
10 changes: 10 additions & 0 deletions docs/source/ttkwidgets/ttkwidgets/ttkwidgets.Shell.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Table
=====

.. currentmodule:: ttkwidgets

.. autoclass:: Shell
:show-inheritance:
:members:

.. automethod:: __init__
21 changes: 21 additions & 0 deletions examples/example_shell.py
Original file line number Diff line number Diff line change
@@ -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()
16 changes: 16 additions & 0 deletions tests/test_shell.py
Original file line number Diff line number Diff line change
@@ -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()))
1 change: 1 addition & 0 deletions ttkwidgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@
from ttkwidgets.timeline import TimeLine
from ttkwidgets.tickscale import TickScale
from ttkwidgets.table import Table
from ttkwidgets.shell import Shell
235 changes: 235 additions & 0 deletions ttkwidgets/shell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import tkinter as tk
import tkinter.font as tkfont
from collections import defaultdict, deque


class Shell(tk.Canvas):
Copy link
Member

Choose a reason for hiding this comment

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

Quite some time ago I started implementing a similar widget on the Console branch. Personally, I would add quite some features, including pressing up and down to cycle through command history, using the tab to have some form of completion...
The textvariable is modifiable by the creator of the widget, which inherently allows the creator to implement all these things. It is, however, not easy to do things with \b or other special characters. Perhaps it is worth considering to implement these things in the base widget.

def __init__(self, master, textvariable=None, prefix='', force_focus=True,
font=None, history_size=1_000, **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('<Key>', self.on_key_press)
self.bind('<Configure>', 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._command_history = deque(maxlen=history_size)
self._history_index = None
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)
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):])
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

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

Choose a reason for hiding this comment

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

On Ubuntu 20.04, GNOME, Python 3.8.2, a padding value of 4 makes the widget disappear until manually resized. A padding size of 2 solves this issue. I understand that this is here for stretching the widget to the size of its master, but, speaking from personal experience, this introduces head-aches like this when working with Canvases.
This code should be tested on different platforms in order to make sure that the widget does not resize itself out of visibility.

Copy link
Member Author

Choose a reason for hiding this comment

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

On windows, a padding of 2 makes the widget grow indefinitely

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 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."""
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)

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

@property
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
"""
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)

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, *messages, end=' '):
"""
Prints a message on the screen

:param *messages: messages to write
:type messages: str
"""
self.buffer = end.join(str(m) for m in messages)
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__':
Copy link
Member

Choose a reason for hiding this comment

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

The example has been moved to a separate file so may be removed here.

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()