Skip to content

Commit

Permalink
1.17.2: add hyperlinks support and revert setupterm(os.devnull) (#147)
Browse files Browse the repository at this point in the history
- move "styles" section from colors.rst to terminal.rst
- add term.link("https://example.com", "example.com") hyperlink support
- add bin/cnn.py news example
- revert this setupterm() experiment #59, discussed in #146
- ignore ValueError for multiprocessing environments #146
  • Loading branch information
jquast authored Feb 3, 2020
1 parent 75e3890 commit dec8baa
Show file tree
Hide file tree
Showing 12 changed files with 253 additions and 47 deletions.
52 changes: 52 additions & 0 deletions bin/cnn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Basic example of hyperlinks -- show CNN news site with clickable URL's."""
# std imports
import random

# 3rd party
import requests

# local
# 3rd-party
from bs4 import BeautifulSoup
# local imports
from blessed import Terminal


def embolden(phrase):
# bold some phrases
return phrase.isdigit() or phrase[:1].isupper()


def make_bold(term, text):
# embolden text
return ' '.join(term.bold(phrase) if embolden(phrase) else phrase
for phrase in text.split(' '))


def whitespace_only(term, line):
# return only left-hand whitespace of `line'.
return line[:term.length(line) - term.length(line.lstrip())]


def find_articles(soup):
return (a_link for a_link in soup.find_all('a') if '/article' in a_link.get('href'))


def main():
term = Terminal()
cnn_url = 'https://lite.cnn.io'
soup = BeautifulSoup(requests.get(cnn_url).content, 'html.parser')
textwrap_kwargs = {
'width': term.width - (term.width // 4),
'initial_indent': ' ' * (term.width // 6) + '* ',
'subsequent_indent': (' ' * (term.width // 6)) + ' ' * 2,
}
for a_href in find_articles(soup):
url_id = int(random.randrange(0, 1 << 24))
for line in term.wrap(make_bold(term, a_href.text), **textwrap_kwargs):
print(whitespace_only(term, line), end='')
print(term.link(cnn_url + a_href.get('href'), line.lstrip(), url_id))


if __name__ == '__main__':
main()
12 changes: 6 additions & 6 deletions blessed/_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,17 +115,17 @@
'scroll_forward': re.escape('\n'),
'set0_des_seq': re.escape('\x1b(B'),
'tab': re.escape('\t'),
# one could get carried away, such as by adding '\x1b#8' (dec tube
# alignment test) by reversing basic vt52, ansi, and xterm sequence
# parsers. There is plans to do just that for ANSI.SYS support.
}

_ANY_NOTESC = '[^' + re.escape('\x1b') + ']*'

CAPABILITIES_ADDITIVES = {
'link': ('link',
re.escape('\x1b') + r'\]8;' + _ANY_NOTESC + ';' +
_ANY_NOTESC + re.escape('\x1b') + '\\\\'),
'color256': ('color', re.escape('\x1b') + r'\[38;5;\d+m'),
'on_color256': ('color', re.escape('\x1b') + r'\[48;5;\d+m'),
'on_color256': ('on_color', re.escape('\x1b') + r'\[48;5;\d+m'),
'color_rgb': ('color_rgb', re.escape('\x1b') + r'\[38;2;\d+;\d+;\d+m'),
'on_color_rgb': ('color_rgb', re.escape('\x1b') + r'\[48;2;\d+;\d+;\d+m'),
'on_color_rgb': ('on_color_rgb', re.escape('\x1b') + r'\[48;2;\d+;\d+;\d+m'),
'shift_in': ('', re.escape('\x0f')),
'shift_out': ('', re.escape('\x0e')),
# sgr(...) outputs strangely, use the basic ANSI/EMCA-48 codes here.
Expand Down
35 changes: 33 additions & 2 deletions blessed/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ def __init__(self, kind=None, stream=None, force_styling=False):
if self.does_styling:
# Initialize curses (call setupterm), so things like tigetstr() work.
try:
curses.setupterm(self._kind, open(os.devnull).fileno())
curses.setupterm(self._kind, self._init_descriptor)
except curses.error as err:
msg = 'Failed to setupterm(kind={0!r}): {1}'.format(self._kind, err)
warnings.warn(msg)
Expand Down Expand Up @@ -210,6 +210,8 @@ def __init__(self, kind=None, stream=None, force_styling=False):
self.__init__keycodes()

def __init__streams(self):
# pylint: disable=too-complex
# Agree to disagree !
stream_fd = None

# Default stream is stdout
Expand All @@ -234,7 +236,10 @@ def __init__streams(self):

# Keyboard valid as stdin only when output stream is stdout or stderr and is a tty.
if self._stream in (sys.__stdout__, sys.__stderr__):
self._keyboard_fd = sys.__stdin__.fileno()
try:
self._keyboard_fd = sys.__stdin__.fileno()
except ValueError:
pass

# _keyboard_fd only non-None if both stdin and stdout is a tty.
self._keyboard_fd = (self._keyboard_fd
Expand Down Expand Up @@ -834,6 +839,32 @@ def normal(self):
self._normal = resolve_capability(self, 'normal')
return self._normal

def link(self, url, text, url_id=''):
"""
Display ``text`` that when touched or clicked, navigates to ``url``.
Optional ``url_id`` may be specified, so that non-adjacent cells can reference a single
target, all cells painted with the same "id" will highlight on hover, rather than any
individual one, as described in "Hovering and underlining the id parameter of gist
https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda.
:param str url: Hyperlink URL.
:param str text: Clickable text.
:param str url_id: Optional 'id'.
:rtype: str
:returns: String of ``text`` as a hyperlink to ``url``.
"""
assert len(url) < 2000, (len(url), url)
if url_id:
assert len(str(url_id)) < 250, (len(str(url_id)), url_id)
params = 'id={0}'.format(url_id)
else:
params = ''
if not self.does_styling:
return text
return ('\x1b]8;{0};{1}\x1b\\{2}'
'\x1b]8;;\x1b\\'.format(params, url, text))

@property
def stream(self):
"""
Expand Down
Binary file added docs/_static/demo_basic_hyperlink.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 0 additions & 20 deletions docs/colors.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,6 @@ And combine two colors using "``_on_``", as in "``foreground_on_background``":

>>> print(term.peru_on_seagreen('All systems functioning within defined parameters.'))

Styles
------

In addition to :doc:`colors`, blessed also supports the limited amount of *styles* that terminals
can do. These are:

``bold``
Turn on 'extra bright' mode.
``reverse``
Switch fore and background attributes.
``normal``
Reset attributes to default.
``underline``
Enable underline mode.
``no_underline``
Disable underline mode.

.. note:: While the inverse of *underline* is *no_underline*, the only way to turn off *bold* or
*reverse* is *normal*, which also cancels any custom colors.

24-bit Colors
-------------

Expand Down
9 changes: 9 additions & 0 deletions docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ https://github.com/jquast/blessed/blob/master/bin/editor.py
This is a very brief, basic primitive non-interactive version of a "classic tennis" video game. It
demonstrates basic timed refresh of a bouncing terminal cell.

.. _cnn.py:

cnn.py
-------------------
https://github.com/jquast/blessed/blob/master/bin/cnn.py

This program uses 3rd-party BeautifulSoup and requests library to fetch the cnn website and display
news article titles using the :meth:`~.Terminal.link` method, so that they may be clicked.

.. _detect-multibyte.py:

detect-multibyte.py
Expand Down
7 changes: 5 additions & 2 deletions docs/history.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
Version History
===============
1.17
* introduced: :ref:`hyperlinks`, method :meth:`~Terminal.link`, :ghissue:`116`.
* introduced: 24-bit color support, detected by ``term.number_of_colors == 1 << 24``, and 24-bit
color foreground method :meth:`~Terminal.color_rgb` and background method
:meth:`~Terminal.on_color_rgb`, as well as 676 common X11 color attribute names are now
Expand All @@ -12,6 +13,8 @@ Version History
:meth:`~Terminal.move_right` which are strings that move the cursor one cell in the respective
direction, are now **also** callables for moving *n* cells to the given direction, such as
``term.move_right(9)``.
* bugfix: prevent ``ValueError: I/O operation on closed file`` on ``sys.stdin`` in multiprocessing
environments, where the keyboard wouldn't work, anyway.
* bugfix: prevent error condition, ``ValueError: underlying buffer has been detached`` in rare
conditions where sys.__stdout__ has been detached in test frameworks. :ghissue:`126`.
* bugfix: off-by-one error in :meth:`~.Terminal.get_location`, now accounts for ``%i`` in
Expand All @@ -29,8 +32,8 @@ Version History
typically supported, anyway. Use Unicode text or 256 or 24-bit color codes instead.
* deprecated: additional key names, such as ``KEY_TAB``, are no longer "injected" into the curses
module namespace.
* deprecated: :func:`curses.setupterm` is now called with :attr:`os.devnull` as the file
descriptor, let us know if this causes any issues. :ghissue:`59`.
* bugfix: briefly tried calling :func:`curses.setupterm` with :attr:`os.devnull` as the file
descriptor, reverted. :ghissue:`59`.
* deprecated: :meth:`~Terminal.inkey` no longer raises RuntimeError when :attr:`~Terminal.stream`
is not a terminal, programs using :meth:`~Terminal.inkey` to block indefinitely if a keyboard is
not attached. :ghissue:`69`.
Expand Down
34 changes: 18 additions & 16 deletions docs/intro.rst
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ Brief Overview
tigetstr_ and tparm_.
* Non-obtrusive calls to only the capabilities database ensures that you are free to mix and match
with calls to any other curses application code or library you like.
* Provides context managers `Terminal.fullscreen()` and `Terminal.hidden_cursor()` to safely express
terminal modes, curses development will no longer fudge up your shell.
* Provides context managers `Terminal.fullscreen()`_ and `Terminal.hidden_cursor()`_ to safely
express terminal modes, curses development will no longer fudge up your shell.
* Act intelligently when somebody redirects your output to a file, omitting all of the special
sequences colors, but still containing all of the text.

Expand Down Expand Up @@ -178,20 +178,22 @@ The same program with *Blessed* is simply:
.. _str.center(): https://docs.python.org/3/library/stdtypes.html#str.center
.. _textwrap.wrap(): https://docs.python.org/3/library/textwrap.html#textwrap.wrap
.. _Terminal: https://blessed.readthedocs.io/en/stable/terminal.html
.. _`Terminal.color_rgb()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.color_rgb
.. _`Terminal.on_color_rgb()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.on_color_rgb
.. _`Terminal.length()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.length
.. _`Terminal.strip()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.strip
.. _`Terminal.rstrip()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.rstrip
.. _`Terminal.lstrip()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.lstrip
.. _`Terminal.strip_seqs()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.strip_seqs
.. _`Terminal.wrap()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.wrap
.. _`Terminal.center()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.center
.. _`Terminal.rjust()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.rjust
.. _`Terminal.ljust()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.ljust
.. _`Terminal.cbreak()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.cbreak
.. _`Terminal.raw()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.raw
.. _`Terminal.inkey()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.inkey
.. _`Terminal.fullscreen()`: https://blessed.readthedocs.io/en/latest/api/terminal.html#blessed.terminal.Terminal.fullscreen
.. _`Terminal.color_rgb()`: https://blessed.readthedocs.io/en/stable/api/terminal.html#blessed.terminal.Terminal.color_rgb
.. _`Terminal.hidden_cursor()`: https://blessed.readthedocs.io/en/latest/api/terminal.html#blessed.terminal.Terminal.hidden_cursor
.. _`Terminal.on_color_rgb()`: https://blessed.readthedocs.io/en/stable/api/terminal.html#blessed.terminal.Terminal.on_color_rgb
.. _`Terminal.length()`: https://blessed.readthedocs.io/en/stable/api/terminal.html#blessed.terminal.Terminal.length
.. _`Terminal.strip()`: https://blessed.readthedocs.io/en/stable/api/terminal.html#blessed.terminal.Terminal.strip
.. _`Terminal.rstrip()`: https://blessed.readthedocs.io/en/stable/api/terminal.html#blessed.terminal.Terminal.rstrip
.. _`Terminal.lstrip()`: https://blessed.readthedocs.io/en/stable/api/terminal.html#blessed.terminal.Terminal.lstrip
.. _`Terminal.strip_seqs()`: https://blessed.readthedocs.io/en/stable/api/terminal.html#blessed.terminal.Terminal.strip_seqs
.. _`Terminal.wrap()`: https://blessed.readthedocs.io/en/stable/api/terminal.html#blessed.terminal.Terminal.wrap
.. _`Terminal.center()`: https://blessed.readthedocs.io/en/stable/api/terminal.html#blessed.terminal.Terminal.center
.. _`Terminal.rjust()`: https://blessed.readthedocs.io/en/stable/api/terminal.html#blessed.terminal.Terminal.rjust
.. _`Terminal.ljust()`: https://blessed.readthedocs.io/en/stable/api/terminal.html#blessed.terminal.Terminal.ljust
.. _`Terminal.cbreak()`: https://blessed.readthedocs.io/en/stable/api/terminal.html#blessed.terminal.Terminal.cbreak
.. _`Terminal.raw()`: https://blessed.readthedocs.io/en/stable/api/terminal.html#blessed.terminal.Terminal.raw
.. _`Terminal.inkey()`: https://blessed.readthedocs.io/en/stable/api/terminal.html#blessed.terminal.Terminal.inkey
.. _Colors: https://blessed.readthedocs.io/en/stable/colors.html
.. _Styles: https://blessed.readthedocs.io/en/stable/colors.html#style
.. _Location: https://blessed.readthedocs.io/en/stable/location.html
Expand Down
37 changes: 37 additions & 0 deletions docs/terminal.rst
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,43 @@ of the screen:

>>> print(term.home + term.on_blue + term.clear)

.. _hyperlinks:

Hyperlinks
----------

Maybe you haven't noticed, because it's a recent addition to terminal emulators, is
that they can now support hyperlinks, like to HTML, or even ``file://`` URLs, which
allows creating clickable links of text.

>>> print(f"blessed {term.link('https://blessed.readthedocs.org', 'documentation')}")
blessed documentation

Hover your cursor over ``documentation``, and it should highlight as a clickable URL.

.. figure:: https://dxtz6bzwq9sxx.cloudfront.net/demo_basic_hyperlink.gif
:alt: Animation of running code example and clicking a hyperlink

Styles
------

In addition to :doc:`colors`, blessed also supports the limited amount of *styles* that terminals
can do. These are:

``bold``
Turn on 'extra bright' mode.
``reverse``
Switch fore and background attributes.
``normal``
Reset attributes to default.
``underline``
Enable underline mode.
``no_underline``
Disable underline mode.

.. note:: While the inverse of *underline* is *no_underline*, the only way to turn off *bold* or
*reverse* is *normal*, which also cancels any custom colors.

Full-Screen Mode
----------------

Expand Down
51 changes: 51 additions & 0 deletions tests/test_length_sequence.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,57 @@ def child(kind, lines=25, cols=80):
child(kind=all_terms)


def test_hyperlink_nostyling():
"""Test length our of hyperlink URL's."""
@as_subprocess
def child():
# given,
term = TestTerminal(force_styling=None)
given_basic_url = term.link(
'https://blessed.readthedocs.org', 'blessed')
assert given_basic_url == 'blessed'

child()


def test_basic_hyperlinks():
"""Test length our of hyperlink URL's."""
@as_subprocess
def child():
# given,
term = TestTerminal()
given_basic_url = term.link(
'https://blessed.readthedocs.org', 'blessed')
split_parts = term.split_seqs(given_basic_url)
assert split_parts[0] == '\x1b]8;;https://blessed.readthedocs.org\x1b\\'
assert term.length(split_parts[0]) == 0
assert ''.join(split_parts[1:8]) == 'blessed'
assert split_parts[8] == '\x1b]8;;\x1b\\'
assert len(split_parts) == 9

child()


def test_hyperlink_with_id():
"""Test length our of hyperlink URL's with ID."""
@as_subprocess
def child():
# given,
term = TestTerminal()
given_advanced_urltext = term.link(
'https://blessed.readthedocs.org', 'blessed', '123')
# exercise,
split_parts = term.split_seqs(given_advanced_urltext)
# verify,
assert split_parts[0] == '\x1b]8;id=123;https://blessed.readthedocs.org\x1b\\'
assert term.length(split_parts[0]) == 0
assert ''.join(split_parts[1:8]) == 'blessed'
assert split_parts[8] == '\x1b]8;;\x1b\\'
assert len(split_parts) == 9

child()


def test_sequence_is_movement_false(all_terms):
"""Test parser about sequences that do not move the cursor."""
@as_subprocess
Expand Down
Loading

0 comments on commit dec8baa

Please sign in to comment.