Update documentation with overview and architecture

This commit is contained in:
rob
2026-01-05 03:44:38 -04:00
parent 5d2bea0b85
commit 32b77c3378
3873 changed files with 722247 additions and 6 deletions
@@ -0,0 +1,343 @@
# Urwid __init__.py - all the stuff you're likely to care about
#
# Copyright (C) 2004-2012 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
import importlib
import sys
import types
import typing
import warnings
from urwid.canvas import (
BlankCanvas,
Canvas,
CanvasCache,
CanvasCombine,
CanvasError,
CanvasJoin,
CanvasOverlay,
CompositeCanvas,
SolidCanvas,
TextCanvas,
)
from urwid.command_map import (
ACTIVATE,
CURSOR_DOWN,
CURSOR_LEFT,
CURSOR_MAX_LEFT,
CURSOR_MAX_RIGHT,
CURSOR_PAGE_DOWN,
CURSOR_PAGE_UP,
CURSOR_RIGHT,
CURSOR_UP,
REDRAW_SCREEN,
CommandMap,
command_map,
)
from urwid.font import (
Font,
FontRegistry,
HalfBlock5x4Font,
HalfBlock6x5Font,
HalfBlock7x7Font,
HalfBlockHeavy6x5Font,
Sextant2x2Font,
Sextant3x3Font,
Thin3x3Font,
Thin4x3Font,
Thin6x6Font,
get_all_fonts,
)
from urwid.signals import (
MetaSignals,
Signals,
connect_signal,
disconnect_signal,
disconnect_signal_by_key,
emit_signal,
register_signal,
)
from urwid.str_util import calc_text_pos, calc_width, is_wide_char, move_next_char, move_prev_char, within_double_byte
from urwid.text_layout import LayoutSegment, StandardTextLayout, TextLayout, default_layout
from urwid.util import (
MetaSuper,
TagMarkupException,
apply_target_encoding,
calc_trim_text,
decompose_tagmarkup,
detected_encoding,
get_encoding_mode,
int_scale,
is_mouse_event,
set_encoding,
supports_unicode,
)
from urwid.version import version as __version__
from urwid.version import version_tuple as __version_tuple__
from . import display, event_loop, widget
from .display import (
BLACK,
BROWN,
DARK_BLUE,
DARK_CYAN,
DARK_GRAY,
DARK_GREEN,
DARK_MAGENTA,
DARK_RED,
DEFAULT,
LIGHT_BLUE,
LIGHT_CYAN,
LIGHT_GRAY,
LIGHT_GREEN,
LIGHT_MAGENTA,
LIGHT_RED,
UPDATE_PALETTE_ENTRY,
WHITE,
YELLOW,
AttrSpec,
AttrSpecError,
BaseScreen,
RealTerminal,
ScreenError,
)
from .event_loop import AsyncioEventLoop, EventLoop, ExitMainLoop, MainLoop, SelectEventLoop
from .widget import (
ANY,
BOTTOM,
BOX,
CENTER,
CLIP,
ELLIPSIS,
FIXED,
FLOW,
GIVEN,
LEFT,
MIDDLE,
PACK,
RELATIVE,
RELATIVE_100,
RIGHT,
SPACE,
TOP,
WEIGHT,
Align,
AttrMap,
AttrMapError,
AttrWrap,
BarGraph,
BarGraphError,
BarGraphMeta,
BigText,
BoxAdapter,
BoxAdapterError,
Button,
CheckBox,
CheckBoxError,
Columns,
ColumnsError,
Divider,
Edit,
EditError,
Filler,
FillerError,
Frame,
FrameError,
GraphVScale,
GridFlow,
GridFlowError,
IntEdit,
LineBox,
ListBox,
ListBoxError,
ListWalker,
ListWalkerError,
MonitoredFocusList,
MonitoredList,
Overlay,
OverlayError,
Padding,
PaddingError,
ParentNode,
Pile,
PileError,
PopUpLauncher,
PopUpTarget,
ProgressBar,
RadioButton,
Scrollable,
ScrollBar,
SelectableIcon,
SimpleFocusListWalker,
SimpleListWalker,
Sizing,
SolidFill,
Text,
TextError,
TreeListBox,
TreeNode,
TreeWalker,
TreeWidget,
TreeWidgetError,
VAlign,
WHSettings,
Widget,
WidgetContainerMixin,
WidgetDecoration,
WidgetDisable,
WidgetError,
WidgetMeta,
WidgetPlaceholder,
WidgetWrap,
WidgetWrapError,
WrapMode,
delegate_to_widget_mixin,
fixed_size,
scale_bar_values,
)
# Optional event loops with external dependencies
try:
from .event_loop import TornadoEventLoop
except ImportError:
pass
try:
from .event_loop import GLibEventLoop
except ImportError:
pass
try:
from .event_loop import TwistedEventLoop
except ImportError:
pass
try:
from .event_loop import TrioEventLoop
except ImportError:
pass
# OS Specific
if sys.platform != "win32":
from .vterm import TermCanvas, TermCharset, Terminal, TermModes
# ZMQEventLoop cause interpreter crash on windows
try:
from .event_loop import ZMQEventLoop
except ImportError:
pass
# Backward compatibility
VERSION = __version_tuple__
# Moved modules handling
__locals: dict[str, typing.Any] = locals() # use mutable access for pure lazy loading
# Backward compatible lazy load with deprecation warnings
_moved_warn: dict[str, str] = {
"lcd_display": "urwid.display.lcd",
"html_fragment": "urwid.display.html_fragment",
"web_display": "urwid.display.web",
"monitored_list": "urwid.widget.monitored_list",
"listbox": "urwid.widget.listbox",
"treetools": "urwid.widget.treetools",
}
# Backward compatible lazy load without any warnings
# Before DeprecationWarning need to start PendingDeprecationWarning process.
_moved_no_warn: dict[str, str] = {
"display_common": "urwid.display.common",
"raw_display": "urwid.display.raw",
"curses_display": "urwid.display.curses",
"escape": "urwid.display.escape",
}
class _MovedModule(types.ModuleType):
"""Special class to handle moved modules.
PEP-0562 handles moved modules attributes, but unfortunately not handle nested modules access
like "from xxx.yyy import zzz"
"""
__slots__ = ("_moved_from", "_moved_to")
def __init__(self, moved_from: str, moved_to: str) -> None:
super().__init__(moved_from.join(".")[-1])
self._moved_from = moved_from
self._moved_to = moved_to
def __getattr__(self, name: str) -> typing.Any:
real_module = importlib.import_module(self._moved_to)
sys.modules[self._moved_from] = real_module
return getattr(real_module, name)
class _MovedModuleWarn(_MovedModule):
"""Special class to handle moved modules.
Produce DeprecationWarning messages for imports.
"""
__slots__ = ()
def __getattr__(self, name: str) -> typing.Any:
warnings.warn(
f"{self._moved_from} is moved to {self._moved_to}",
DeprecationWarning,
stacklevel=2,
)
return super().__getattr__(name)
for _name, _module in _moved_no_warn.items():
_module_path = f"{__name__}.{_name}"
sys.modules[_module_path] = _MovedModule(_module_path, _module)
for _name, _module in _moved_warn.items():
_module_path = f"{__name__}.{_name}"
sys.modules[_module_path] = _MovedModuleWarn(_module_path, _module)
def __getattr__(name: str) -> typing.Any:
"""Get attributes lazy.
:return: attribute by name
:raises AttributeError: attribute is not defined for lazy load
"""
if name in _moved_no_warn:
mod = importlib.import_module(_moved_no_warn[name])
__locals[name] = mod
return mod
if name in _moved_warn:
warnings.warn(
f"{name} is moved to {_moved_warn[name]}",
DeprecationWarning,
stacklevel=2,
)
mod = importlib.import_module(_moved_warn[name])
__locals[name] = mod
return mod
raise AttributeError(f"{name} not found in {__package__}")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,139 @@
# Urwid CommandMap class
# Copyright (C) 2004-2011 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
import enum
import typing
from collections.abc import MutableMapping
if typing.TYPE_CHECKING:
from collections.abc import Iterator
from typing_extensions import Self
class Command(str, enum.Enum):
REDRAW_SCREEN = "redraw screen"
UP = "cursor up"
DOWN = "cursor down"
LEFT = "cursor left"
RIGHT = "cursor right"
PAGE_UP = "cursor page up"
PAGE_DOWN = "cursor page down"
MAX_LEFT = "cursor max left"
MAX_RIGHT = "cursor max right"
ACTIVATE = "activate"
MENU = "menu"
SELECT_NEXT = "next selectable"
SELECT_PREVIOUS = "prev selectable"
REDRAW_SCREEN = Command.REDRAW_SCREEN
CURSOR_UP = Command.UP
CURSOR_DOWN = Command.DOWN
CURSOR_LEFT = Command.LEFT
CURSOR_RIGHT = Command.RIGHT
CURSOR_PAGE_UP = Command.PAGE_UP
CURSOR_PAGE_DOWN = Command.PAGE_DOWN
CURSOR_MAX_LEFT = Command.MAX_LEFT
CURSOR_MAX_RIGHT = Command.MAX_RIGHT
ACTIVATE = Command.ACTIVATE
class CommandMap(MutableMapping[str, typing.Union[str, Command, None]]):
"""
dict-like object for looking up commands from keystrokes
Default values (key: command)::
'tab': 'next selectable',
'ctrl n': 'next selectable',
'shift tab': 'prev selectable',
'ctrl p': 'prev selectable',
'ctrl l': 'redraw screen',
'esc': 'menu',
'up': 'cursor up',
'down': 'cursor down',
'left': 'cursor left',
'right': 'cursor right',
'page up': 'cursor page up',
'page down': 'cursor page down',
'home': 'cursor max left',
'end': 'cursor max right',
' ': 'activate',
'enter': 'activate',
"""
def __iter__(self) -> Iterator[str]:
return iter(self._command)
def __len__(self) -> int:
return len(self._command)
_command_defaults: typing.ClassVar[dict[str, str | Command]] = {
"tab": Command.SELECT_NEXT,
"ctrl n": Command.SELECT_NEXT,
"shift tab": Command.SELECT_PREVIOUS,
"ctrl p": Command.SELECT_PREVIOUS,
"ctrl l": Command.REDRAW_SCREEN,
"esc": Command.MENU,
"up": Command.UP,
"down": Command.DOWN,
"left": Command.LEFT,
"right": Command.RIGHT,
"page up": Command.PAGE_UP,
"page down": Command.PAGE_DOWN,
"home": Command.MAX_LEFT,
"end": Command.MAX_RIGHT,
" ": Command.ACTIVATE,
"enter": Command.ACTIVATE,
}
def __init__(self) -> None:
self._command = self._command_defaults.copy()
def restore_defaults(self) -> None:
self._command = self._command_defaults.copy()
def __getitem__(self, key: str) -> str | Command | None:
return self._command.get(key, None)
def __setitem__(self, key, command: str | Command) -> None:
self._command[key] = command
def __delitem__(self, key: str) -> None:
del self._command[key]
def clear_command(self, command: str | Command) -> None:
dk = [k for k, v in self._command.items() if v == command]
for k in dk:
del self._command[k]
def copy(self) -> Self:
"""
Return a new copy of this CommandMap, likely so we can modify
it separate from a shared one.
"""
c = self.__class__()
c._command = dict(self._command) # pylint: disable=protected-access
return c
command_map = CommandMap() # shared command mappings
@@ -0,0 +1,59 @@
# Urwid container widget classes
# Copyright (C) 2004-2012 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
import warnings
from urwid.widget import (
Columns,
ColumnsError,
Frame,
FrameError,
GridFlow,
GridFlowError,
Overlay,
OverlayError,
Pile,
PileError,
WidgetContainerMixin,
)
__all__ = (
"Columns",
"ColumnsError",
"Frame",
"FrameError",
"GridFlow",
"GridFlowError",
"Overlay",
"OverlayError",
"Pile",
"PileError",
"WidgetContainerMixin",
)
warnings.warn(
f"{__name__!r} is not expected to be imported directly. "
'Please use public access from "urwid" package. '
f"Module {__name__!r} is deprecated and will be removed in the version 4.0.",
DeprecationWarning,
stacklevel=3,
)
@@ -0,0 +1,65 @@
# Urwid widget decoration classes
# Copyright (C) 2004-2012 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
import warnings
from urwid.widget import (
AttrMap,
AttrMapError,
AttrWrap,
BoxAdapter,
BoxAdapterError,
Filler,
FillerError,
Padding,
PaddingError,
WidgetDecoration,
WidgetDisable,
WidgetPlaceholder,
calculate_top_bottom_filler,
normalize_valign,
)
__all__ = (
"AttrMap",
"AttrMapError",
"AttrWrap",
"BoxAdapter",
"BoxAdapterError",
"Filler",
"FillerError",
"Padding",
"PaddingError",
"WidgetDecoration",
"WidgetDisable",
"WidgetPlaceholder",
"calculate_top_bottom_filler",
"normalize_valign",
)
warnings.warn(
f"{__name__!r} is not expected to be imported directly. "
'Please use public access from "urwid" package. '
f"Module {__name__!r} is deprecated and will be removed in the version 4.0.",
DeprecationWarning,
stacklevel=3,
)
@@ -0,0 +1,97 @@
"""Package with Display implementations for urwid."""
from __future__ import annotations
import importlib
import typing
__all__ = (
"BLACK",
"BROWN",
"DARK_BLUE",
"DARK_CYAN",
"DARK_GRAY",
"DARK_GREEN",
"DARK_MAGENTA",
"DARK_RED",
"DEFAULT",
"LIGHT_BLUE",
"LIGHT_CYAN",
"LIGHT_GRAY",
"LIGHT_GREEN",
"LIGHT_MAGENTA",
"LIGHT_RED",
"UPDATE_PALETTE_ENTRY",
"WHITE",
"YELLOW",
"AttrSpec",
"AttrSpecError",
"BaseScreen",
"RealTerminal",
"ScreenError",
# Lazy imported
"html_fragment",
"lcd",
"raw",
"web",
)
from . import raw
from .common import (
BLACK,
BROWN,
DARK_BLUE,
DARK_CYAN,
DARK_GRAY,
DARK_GREEN,
DARK_MAGENTA,
DARK_RED,
DEFAULT,
LIGHT_BLUE,
LIGHT_CYAN,
LIGHT_GRAY,
LIGHT_GREEN,
LIGHT_MAGENTA,
LIGHT_RED,
UPDATE_PALETTE_ENTRY,
WHITE,
YELLOW,
AttrSpec,
AttrSpecError,
BaseScreen,
RealTerminal,
ScreenError,
)
try:
from . import curses
__all__ += ("curses",)
except ImportError:
pass
# Moved modules handling
__locals: dict[str, typing.Any] = locals() # use mutable access for pure lazy loading
# Lazy load modules
_lazy_load: frozenset[str] = frozenset(
(
"html_fragment",
"lcd",
"web",
)
)
def __getattr__(name: str) -> typing.Any:
"""Get attributes lazy.
:return: attribute by name
:raises AttributeError: attribute is not defined for lazy load
"""
if name in _lazy_load:
mod = importlib.import_module(f"{__package__}.{name}")
__locals[name] = mod
return mod
raise AttributeError(f"{name} not found in {__package__}")
@@ -0,0 +1,424 @@
# Urwid raw display module
# Copyright (C) 2004-2009 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
"""
Direct terminal UI implementation
"""
from __future__ import annotations
import contextlib
import fcntl
import functools
import os
import selectors
import signal
import struct
import sys
import termios
import tty
import typing
from subprocess import PIPE, Popen
from urwid import signals
from . import _raw_display_base, escape
from .common import INPUT_DESCRIPTORS_CHANGED
if typing.TYPE_CHECKING:
import socket
from collections.abc import Callable
from types import FrameType
from urwid.event_loop import EventLoop
class Screen(_raw_display_base.Screen):
def __init__(
self,
input: typing.TextIO = sys.stdin, # noqa: A002 # pylint: disable=redefined-builtin
output: typing.TextIO = sys.stdout,
bracketed_paste_mode=False,
focus_reporting=False,
) -> None:
"""Initialize a screen that directly prints escape codes to an output
terminal.
bracketed_paste_mode -- enable bracketed paste mode in the host terminal.
If the host terminal supports it, the application will receive `begin paste`
and `end paste` keystrokes when the user pastes text.
focus_reporting -- enable focus reporting in the host terminal.
If the host terminal supports it, the application will receive `focus in`
and `focus out` keystrokes when the application gains and loses focus.
"""
super().__init__(input, output)
self.gpm_mev: Popen | None = None
self.gpm_event_pending: bool = False
self.bracketed_paste_mode = bracketed_paste_mode
self.focus_reporting = focus_reporting
# These store the previous signal handlers after setting ours
self._prev_sigcont_handler = None
self._prev_sigtstp_handler = None
self._prev_sigwinch_handler = None
def __repr__(self) -> str:
return (
f"<{self.__class__.__name__}("
f"input={self._term_input_file}, "
f"output={self._term_output_file}, "
f"bracketed_paste_mode={self.bracketed_paste_mode}, "
f"focus_reporting={self.focus_reporting})>"
)
def _sigwinch_handler(self, signum: int = 28, frame: FrameType | None = None) -> None:
"""
frame -- will always be None when the GLib event loop is being used.
"""
super()._sigwinch_handler(signum, frame)
if callable(self._prev_sigwinch_handler):
self._prev_sigwinch_handler(signum, frame)
def _sigtstp_handler(self, signum: int, frame: FrameType | None = None) -> None:
self.stop() # Restores the previous signal handlers
self._prev_sigcont_handler = self.signal_handler_setter(signal.SIGCONT, self._sigcont_handler)
# Handled by the previous handler.
# If non-default, it may set its own SIGCONT handler which should hopefully call our own.
os.kill(os.getpid(), signal.SIGTSTP)
def _sigcont_handler(self, signum: int, frame: FrameType | None = None) -> None:
"""
frame -- will always be None when the GLib event loop is being used.
"""
self.signal_restore()
if callable(self._prev_sigcont_handler):
# May set its own SIGTSTP handler which would be stored and replaced in
# `signal_init()` (via `start()`).
self._prev_sigcont_handler(signum, frame)
self.start()
self._sigwinch_handler(28, None)
def signal_init(self) -> None:
"""
Called in the startup of run wrapper to set the SIGWINCH
and SIGTSTP signal handlers.
Override this function to call from main thread in threaded
applications.
"""
self._prev_sigwinch_handler = self.signal_handler_setter(signal.SIGWINCH, self._sigwinch_handler)
self._prev_sigtstp_handler = self.signal_handler_setter(signal.SIGTSTP, self._sigtstp_handler)
def signal_restore(self) -> None:
"""
Called in the finally block of run wrapper to restore the
SIGTSTP, SIGCONT and SIGWINCH signal handlers.
Override this function to call from main thread in threaded
applications.
"""
self.signal_handler_setter(signal.SIGTSTP, self._prev_sigtstp_handler or signal.SIG_DFL)
self.signal_handler_setter(signal.SIGCONT, self._prev_sigcont_handler or signal.SIG_DFL)
self.signal_handler_setter(signal.SIGWINCH, self._prev_sigwinch_handler or signal.SIG_DFL)
def _mouse_tracking(self, enable: bool) -> None:
super()._mouse_tracking(enable)
if enable:
self._start_gpm_tracking()
else:
self._stop_gpm_tracking()
def _start_gpm_tracking(self) -> None:
if not os.path.isfile("/usr/bin/mev"):
return
if not os.environ.get("TERM", "").lower().startswith("linux"):
return
m = Popen( # pylint: disable=consider-using-with
["/usr/bin/mev", "-e", "158"],
stdin=PIPE,
stdout=PIPE,
close_fds=True,
encoding="ascii",
)
fcntl.fcntl(m.stdout.fileno(), fcntl.F_SETFL, os.O_NONBLOCK)
self.gpm_mev = m
def _stop_gpm_tracking(self) -> None:
if not self.gpm_mev:
return
os.kill(self.gpm_mev.pid, signal.SIGINT)
os.waitpid(self.gpm_mev.pid, 0)
self.gpm_mev = None
def _start(self, alternate_buffer: bool = True) -> None:
"""
Initialize the screen and input mode.
alternate_buffer -- use an alternate screen buffer
"""
if alternate_buffer:
self.write(escape.SWITCH_TO_ALTERNATE_BUFFER)
self._rows_used = None
else:
self._rows_used = 0
if self.bracketed_paste_mode:
self.write(escape.ENABLE_BRACKETED_PASTE_MODE)
if self.focus_reporting:
self.write(escape.ENABLE_FOCUS_REPORTING)
fd = self._input_fileno()
if fd is not None and os.isatty(fd):
self._old_termios_settings = termios.tcgetattr(fd)
tty.setcbreak(fd)
self.signal_init()
self._alternate_buffer = alternate_buffer
self._next_timeout = self.max_wait
if not self._signal_keys_set:
self._old_signal_keys = self.tty_signal_keys(fileno=fd)
signals.emit_signal(self, INPUT_DESCRIPTORS_CHANGED)
# restore mouse tracking to previous state
self._mouse_tracking(self._mouse_tracking_enabled)
return super()._start()
def _stop(self) -> None:
"""
Restore the screen.
"""
self.clear()
if self.bracketed_paste_mode:
self.write(escape.DISABLE_BRACKETED_PASTE_MODE)
if self.focus_reporting:
self.write(escape.DISABLE_FOCUS_REPORTING)
signals.emit_signal(self, INPUT_DESCRIPTORS_CHANGED)
self.signal_restore()
self._stop_mouse_restore_buffer()
fd = self._input_fileno()
if fd is not None and os.isatty(fd):
termios.tcsetattr(fd, termios.TCSAFLUSH, self._old_termios_settings)
if self._old_signal_keys:
self.tty_signal_keys(*self._old_signal_keys, fd)
super()._stop()
def get_input_descriptors(self) -> list[socket.socket | typing.IO | int]:
"""
Return a list of integer file descriptors that should be
polled in external event loops to check for user input.
Use this method if you are implementing your own event loop.
This method is only called by `hook_event_loop`, so if you override
that, you can safely ignore this.
"""
if not self._started:
return []
fd_list = super().get_input_descriptors()
if self.gpm_mev is not None and self.gpm_mev.stdout is not None:
fd_list.append(self.gpm_mev.stdout)
return fd_list
def unhook_event_loop(self, event_loop: EventLoop) -> None:
"""
Remove any hooks added by hook_event_loop.
"""
for handle in self._current_event_loop_handles:
event_loop.remove_watch_file(handle)
if self._input_timeout:
event_loop.remove_alarm(self._input_timeout)
self._input_timeout = None
def hook_event_loop(
self,
event_loop: EventLoop,
callback: Callable[[list[str], list[int]], typing.Any],
) -> None:
"""
Register the given callback with the event loop, to be called with new
input whenever it's available. The callback should be passed a list of
processed keys and a list of unprocessed keycodes.
Subclasses may wish to use parse_input to wrap the callback.
"""
if hasattr(self, "get_input_nonblocking"):
wrapper = self._make_legacy_input_wrapper(event_loop, callback)
else:
@functools.wraps(callback)
def wrapper() -> tuple[list[str], typing.Any] | None:
self.logger.debug('Calling callback for "watch file"')
return self.parse_input(event_loop, callback, self.get_available_raw_input())
fds = self.get_input_descriptors()
handles = [event_loop.watch_file(fd if isinstance(fd, int) else fd.fileno(), wrapper) for fd in fds]
self._current_event_loop_handles = handles
def _get_input_codes(self) -> list[int]:
return super()._get_input_codes() + self._get_gpm_codes()
def _get_gpm_codes(self) -> list[int]:
codes = []
try:
while self.gpm_mev is not None and self.gpm_event_pending:
codes.extend(self._encode_gpm_event())
except OSError as e:
if e.args[0] != 11:
raise
return codes
def _read_raw_input(self, timeout: int) -> bytearray:
ready = self._wait_for_input_ready(timeout)
if self.gpm_mev is not None and self.gpm_mev.stdout.fileno() in ready:
self.gpm_event_pending = True
fd = self._input_fileno()
chars = bytearray()
if fd is None or fd not in ready:
return chars
with selectors.DefaultSelector() as selector:
selector.register(fd, selectors.EVENT_READ)
input_ready = selector.select(0)
while input_ready:
chars.extend(os.read(fd, 1024))
input_ready = selector.select(0)
return chars
def _encode_gpm_event(self) -> list[int]:
self.gpm_event_pending = False
s = self.gpm_mev.stdout.readline()
result = s.split(", ")
if len(result) != 6:
# unexpected output, stop tracking
self._stop_gpm_tracking()
signals.emit_signal(self, INPUT_DESCRIPTORS_CHANGED)
return []
ev, x, y, _ign, b, m = s.split(",")
ev = int(ev.split("x")[-1], 16)
x = int(x.split(" ")[-1])
y = int(y.lstrip().split(" ")[0])
b = int(b.split(" ")[-1])
m = int(m.split("x")[-1].rstrip(), 16)
# convert to xterm-like escape sequence
last_state = next_state = self.last_bstate
result = []
mod = 0
if m & 1:
mod |= 4 # shift
if m & 10:
mod |= 8 # alt
if m & 4:
mod |= 16 # ctrl
def append_button(b: int) -> None:
b |= mod
result.extend([27, ord("["), ord("M"), b + 32, x + 32, y + 32])
if ev in {20, 36, 52}: # press
if b & 4 and last_state & 1 == 0:
append_button(0)
next_state |= 1
if b & 2 and last_state & 2 == 0:
append_button(1)
next_state |= 2
if b & 1 and last_state & 4 == 0:
append_button(2)
next_state |= 4
elif ev == 146: # drag
if b & 4:
append_button(0 + escape.MOUSE_DRAG_FLAG)
elif b & 2:
append_button(1 + escape.MOUSE_DRAG_FLAG)
elif b & 1:
append_button(2 + escape.MOUSE_DRAG_FLAG)
else: # release
if b & 4 and last_state & 1:
append_button(0 + escape.MOUSE_RELEASE_FLAG)
next_state &= ~1
if b & 2 and last_state & 2:
append_button(1 + escape.MOUSE_RELEASE_FLAG)
next_state &= ~2
if b & 1 and last_state & 4:
append_button(2 + escape.MOUSE_RELEASE_FLAG)
next_state &= ~4
if ev == 40: # double click (release)
if b & 4 and last_state & 1:
append_button(0 + escape.MOUSE_MULTIPLE_CLICK_FLAG)
if b & 2 and last_state & 2:
append_button(1 + escape.MOUSE_MULTIPLE_CLICK_FLAG)
if b & 1 and last_state & 4:
append_button(2 + escape.MOUSE_MULTIPLE_CLICK_FLAG)
elif ev == 52:
if b & 4 and last_state & 1:
append_button(0 + escape.MOUSE_MULTIPLE_CLICK_FLAG * 2)
if b & 2 and last_state & 2:
append_button(1 + escape.MOUSE_MULTIPLE_CLICK_FLAG * 2)
if b & 1 and last_state & 4:
append_button(2 + escape.MOUSE_MULTIPLE_CLICK_FLAG * 2)
self.last_bstate = next_state
return result
def get_cols_rows(self) -> tuple[int, int]:
"""Return the terminal dimensions (num columns, num rows)."""
y, x = super().get_cols_rows()
with contextlib.suppress(OSError): # Term size could not be determined
if hasattr(self._term_output_file, "fileno"):
buf = fcntl.ioctl(self._term_output_file.fileno(), termios.TIOCGWINSZ, b" " * 8)
y, x, _, _ = struct.unpack("hhhh", buf)
# Provide some lightweight fallbacks in case the TIOCWINSZ doesn't
# give sane answers
if (x <= 0 or y <= 0) and self.term in {"ansi", "vt100"}:
y, x = 24, 80
self.maxrow = y
return x, y
def _test():
import doctest
doctest.testmod()
if __name__ == "__main__":
_test()
@@ -0,0 +1,922 @@
# Urwid raw display module
# Copyright (C) 2004-2009 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
"""
Direct terminal UI implementation
"""
from __future__ import annotations
import abc
import contextlib
import functools
import os
import platform
import selectors
import signal
import socket
import sys
import typing
from urwid import signals, str_util, util
from . import escape
from .common import UNPRINTABLE_TRANS_TABLE, UPDATE_PALETTE_ENTRY, AttrSpec, BaseScreen, RealTerminal
if typing.TYPE_CHECKING:
from collections.abc import Callable, Iterable
from types import FrameType
from typing_extensions import Literal
from urwid import Canvas, EventLoop
IS_WINDOWS = sys.platform == "win32"
IS_WSL = (sys.platform == "linux") and ("wsl" in platform.platform().lower())
class Screen(BaseScreen, RealTerminal):
def __init__(self, input: typing.IO, output: typing.IO) -> None: # noqa: A002 # pylint: disable=redefined-builtin
"""Initialize a screen that directly prints escape codes to an output
terminal.
"""
super().__init__()
self._partial_codes: list[int] = []
self._pal_escape: dict[str | None, str] = {}
self._pal_attrspec: dict[str | None, AttrSpec] = {}
self._alternate_buffer: bool = False
signals.connect_signal(self, UPDATE_PALETTE_ENTRY, self._on_update_palette_entry)
self.colors: Literal[1, 16, 88, 256, 16777216] = 16 # FIXME: detect this
self.has_underline = True # FIXME: detect this
self.prev_input_resize = 0
self.set_input_timeouts()
self.screen_buf = None
self._screen_buf_canvas = None
self._resized = False
self.maxrow = None
self._mouse_tracking_enabled = False
self.last_bstate = 0
self._setup_G1_done = False
self._rows_used = None
self._cy = 0
self.term = os.environ.get("TERM", "")
self.fg_bright_is_bold = not self.term.startswith("xterm")
self.bg_bright_is_blink = self.term == "linux"
self.back_color_erase = not self.term.startswith("screen")
self.register_palette_entry(None, "default", "default")
self._next_timeout = None
self.signal_handler_setter = signal.signal
# Our connections to the world
self._term_output_file = output
self._term_input_file = input
# pipe for signalling external event loops about resize events
self._resize_pipe_rd, self._resize_pipe_wr = socket.socketpair()
self._resize_pipe_rd.setblocking(False)
def __del__(self) -> None:
self._resize_pipe_rd.close()
self._resize_pipe_wr.close()
def __repr__(self) -> str:
return f"<{self.__class__.__name__}(input={self._term_input_file}, output={self._term_output_file})>"
def _sigwinch_handler(self, signum: int = 28, frame: FrameType | None = None) -> None:
"""
frame -- will always be None when the GLib event loop is being used.
"""
logger = self.logger.getChild("signal_handlers")
logger.debug(f"SIGWINCH handler called with signum={signum!r}, frame={frame!r}")
if IS_WINDOWS or not self._resized:
self._resize_pipe_wr.send(b"R")
logger.debug("Sent fake resize input to the pipe")
self._resized = True
self.screen_buf = None
@property
def _term_input_io(self) -> typing.IO | None:
if hasattr(self._term_input_file, "fileno"):
return self._term_input_file
return None
def _input_fileno(self) -> int | None:
"""Returns the fileno of the input stream, or None if it doesn't have one.
A stream without a fileno can't participate in whatever.
"""
if hasattr(self._term_input_file, "fileno"):
return self._term_input_file.fileno()
return None
def _on_update_palette_entry(self, name: str | None, *attrspecs: AttrSpec):
# copy the attribute to a dictionary containing the escape seqences
a: AttrSpec = attrspecs[{16: 0, 1: 1, 88: 2, 256: 3, 2**24: 4}[self.colors]]
self._pal_attrspec[name] = a
self._pal_escape[name] = self._attrspec_to_escape(a)
def set_input_timeouts(
self,
max_wait: float | None = None,
complete_wait: float = 0.125,
resize_wait: float = 0.125,
) -> None:
"""
Set the get_input timeout values. All values are in floating
point numbers of seconds.
max_wait -- amount of time in seconds to wait for input when
there is no input pending, wait forever if None
complete_wait -- amount of time in seconds to wait when
get_input detects an incomplete escape sequence at the
end of the available input
resize_wait -- amount of time in seconds to wait for more input
after receiving two screen resize requests in a row to
stop Urwid from consuming 100% cpu during a gradual
window resize operation
"""
self.max_wait = max_wait
if max_wait is not None:
if self._next_timeout is None:
self._next_timeout = max_wait
else:
self._next_timeout = min(self._next_timeout, self.max_wait)
self.complete_wait = complete_wait
self.resize_wait = resize_wait
def set_mouse_tracking(self, enable: bool = True) -> None:
"""
Enable (or disable) mouse tracking.
After calling this function get_input will include mouse
click events along with keystrokes.
"""
enable = bool(enable)
if enable == self._mouse_tracking_enabled:
return
self._mouse_tracking(enable)
self._mouse_tracking_enabled = enable
def _mouse_tracking(self, enable: bool) -> None:
if enable:
self.write(escape.MOUSE_TRACKING_ON)
else:
self.write(escape.MOUSE_TRACKING_OFF)
@abc.abstractmethod
def _start(self, alternate_buffer: bool = True) -> None:
"""
Initialize the screen and input mode.
alternate_buffer -- use alternate screen buffer
"""
def _stop_mouse_restore_buffer(self) -> None:
"""Stop mouse tracking and restore the screen."""
self._mouse_tracking(False)
move_cursor = ""
if self._alternate_buffer:
move_cursor = escape.RESTORE_NORMAL_BUFFER
elif self.maxrow is not None:
move_cursor = escape.set_cursor_position(0, self.maxrow)
self.write(self._attrspec_to_escape(AttrSpec("", "")) + escape.SI + move_cursor + escape.SHOW_CURSOR)
self.flush()
@abc.abstractmethod
def _stop(self) -> None:
"""
Restore the screen.
"""
def write(self, data: str) -> None:
"""Write some data to the terminal.
You may wish to override this if you're using something other than
regular files for input and output.
"""
self._term_output_file.write(data)
def flush(self) -> None:
"""Flush the output buffer.
You may wish to override this if you're using something other than
regular files for input and output.
"""
self._term_output_file.flush()
@typing.overload
def get_input(self, raw_keys: Literal[False]) -> list[str]: ...
@typing.overload
def get_input(self, raw_keys: Literal[True]) -> tuple[list[str], list[int]]: ...
def get_input(self, raw_keys: bool = False) -> list[str] | tuple[list[str], list[int]]:
"""Return pending input as a list.
raw_keys -- return raw keycodes as well as translated versions
This function will immediately return all the input since the
last time it was called. If there is no input pending it will
wait before returning an empty list. The wait time may be
configured with the set_input_timeouts function.
If raw_keys is False (default) this function will return a list
of keys pressed. If raw_keys is True this function will return
a ( keys pressed, raw keycodes ) tuple instead.
Examples of keys returned:
* ASCII printable characters: " ", "a", "0", "A", "-", "/"
* ASCII control characters: "tab", "enter"
* Escape sequences: "up", "page up", "home", "insert", "f1"
* Key combinations: "shift f1", "meta a", "ctrl b"
* Window events: "window resize"
When a narrow encoding is not enabled:
* "Extended ASCII" characters: "\\xa1", "\\xb2", "\\xfe"
When a wide encoding is enabled:
* Double-byte characters: "\\xa1\\xea", "\\xb2\\xd4"
When utf8 encoding is enabled:
* Unicode characters: u"\\u00a5", u'\\u253c"
Examples of mouse events returned:
* Mouse button press: ('mouse press', 1, 15, 13),
('meta mouse press', 2, 17, 23)
* Mouse drag: ('mouse drag', 1, 16, 13),
('mouse drag', 1, 17, 13),
('ctrl mouse drag', 1, 18, 13)
* Mouse button release: ('mouse release', 0, 18, 13),
('ctrl mouse release', 0, 17, 23)
"""
logger = self.logger.getChild("get_input")
if not self._started:
raise RuntimeError
self._wait_for_input_ready(self._next_timeout)
keys, raw = self.parse_input(None, None, self.get_available_raw_input())
# Avoid pegging CPU at 100% when slowly resizing
if keys == ["window resize"] and self.prev_input_resize:
logger.debug('get_input: got "window resize" > 1 times. Enable throttling for resize.')
for _ in range(2):
self._wait_for_input_ready(self.resize_wait)
new_keys, new_raw = self.parse_input(None, None, self.get_available_raw_input())
raw += new_raw
if new_keys and new_keys != ["window resize"]:
if "window resize" in new_keys:
keys = new_keys
else:
keys.extend(new_keys)
break
if keys == ["window resize"]:
self.prev_input_resize = 2
elif self.prev_input_resize == 2 and not keys:
self.prev_input_resize = 1
else:
self.prev_input_resize = 0
if raw_keys:
return keys, raw
return keys
def get_input_descriptors(self) -> list[socket.socket | typing.IO | int]:
"""
Return a list of integer file descriptors that should be
polled in external event loops to check for user input.
Use this method if you are implementing your own event loop.
This method is only called by `hook_event_loop`, so if you override
that, you can safely ignore this.
"""
if not self._started:
return []
fd_list: list[socket.socket | typing.IO | int] = [self._resize_pipe_rd]
if (input_io := self._term_input_io) is not None:
fd_list.append(input_io)
return fd_list
_current_event_loop_handles = ()
@abc.abstractmethod
def unhook_event_loop(self, event_loop: EventLoop) -> None:
"""
Remove any hooks added by hook_event_loop.
"""
@abc.abstractmethod
def hook_event_loop(
self,
event_loop: EventLoop,
callback: Callable[[list[str], list[int]], typing.Any],
) -> None:
"""
Register the given callback with the event loop, to be called with new
input whenever it's available. The callback should be passed a list of
processed keys and a list of unprocessed keycodes.
Subclasses may wish to use parse_input to wrap the callback.
"""
_input_timeout = None
def _make_legacy_input_wrapper(
self,
event_loop: EventLoop,
callback: Callable[[list[str], list[int]], typing.Any],
) -> Callable[[], None]:
"""
Support old Screen classes that still have a get_input_nonblocking and expect it to work.
"""
@functools.wraps(callback)
def wrapper():
if self._input_timeout:
event_loop.remove_alarm(self._input_timeout)
self._input_timeout = None
timeout, keys, raw = self.get_input_nonblocking() # pylint: disable=no-member # should we deprecate?
if timeout is not None:
self._input_timeout = event_loop.alarm(timeout, wrapper)
callback(keys, raw)
return wrapper
def _get_input_codes(self) -> list[int]:
return list(self._get_keyboard_codes())
def get_available_raw_input(self) -> list[int]:
"""
Return any currently available input. Does not block.
This method is only used by the default `hook_event_loop`
implementation; you can safely ignore it if you implement your own.
"""
logger = self.logger.getChild("get_available_raw_input")
codes = [*self._partial_codes, *self._get_input_codes()]
self._partial_codes = []
# clean out the pipe used to signal external event loops
# that a resize has occurred
with selectors.DefaultSelector() as selector:
selector.register(self._resize_pipe_rd, selectors.EVENT_READ)
present_resize_flag = selector.select(0) # nonblocking
while present_resize_flag:
logger.debug("Resize signal received. Cleaning socket.")
# Argument "size" is maximum buffer size to read. Since we're emptying, set it reasonably big.
self._resize_pipe_rd.recv(128)
present_resize_flag = selector.select(0)
return codes
@typing.overload
def parse_input(
self,
event_loop: None,
callback: None,
codes: list[int],
wait_for_more: bool = ...,
) -> tuple[list[str], list[int]]: ...
@typing.overload
def parse_input(
self,
event_loop: EventLoop,
callback: None,
codes: list[int],
wait_for_more: bool = ...,
) -> tuple[list[str], list[int]]: ...
@typing.overload
def parse_input(
self,
event_loop: EventLoop,
callback: Callable[[list[str], list[int]], typing.Any],
codes: list[int],
wait_for_more: bool = ...,
) -> None: ...
def parse_input(
self,
event_loop: EventLoop | None,
callback: Callable[[list[str], list[int]], typing.Any] | None,
codes: list[int],
wait_for_more: bool = True,
) -> tuple[list[str], list[int]] | None:
"""
Read any available input from get_available_raw_input, parses it into
keys, and calls the given callback.
The current implementation tries to avoid any assumptions about what
the screen or event loop look like; it only deals with parsing keycodes
and setting a timeout when an incomplete one is detected.
`codes` should be a sequence of keycodes, i.e. bytes. A bytearray is
appropriate, but beware of using bytes, which only iterates as integers
on Python 3.
"""
logger = self.logger.getChild("parse_input")
# Note: event_loop may be None for 100% synchronous support, only used
# by get_input. Not documented because you shouldn't be doing it.
if self._input_timeout and event_loop:
event_loop.remove_alarm(self._input_timeout)
self._input_timeout = None
original_codes = codes
decoded_codes = []
try:
while codes:
run, codes = escape.process_keyqueue(codes, wait_for_more)
decoded_codes.extend(run)
except escape.MoreInputRequired:
# Set a timer to wait for the rest of the input; if it goes off
# without any new input having come in, use the partial input
k = len(original_codes) - len(codes)
raw_codes = original_codes[:k]
self._partial_codes = codes
def _parse_incomplete_input():
self._input_timeout = None
self._partial_codes = []
self.parse_input(event_loop, callback, codes, wait_for_more=False)
if event_loop:
self._input_timeout = event_loop.alarm(self.complete_wait, _parse_incomplete_input)
else:
raw_codes = original_codes
self._partial_codes = []
logger.debug(f"Decoded codes: {decoded_codes!r}, raw codes: {raw_codes!r}")
if self._resized:
decoded_codes.append("window resize")
logger.debug('Added "window resize" to the codes')
self._resized = False
if callback:
callback(decoded_codes, raw_codes)
return None
# For get_input
return decoded_codes, raw_codes
def _wait_for_input_ready(self, timeout: float | None) -> list[int]:
logger = self.logger.getChild("wait_for_input_ready")
fd_list = self.get_input_descriptors()
logger.debug(f"Waiting for input: descriptors={fd_list!r}, timeout={timeout!r}")
with selectors.DefaultSelector() as selector:
for fd in fd_list:
selector.register(fd, selectors.EVENT_READ)
ready = selector.select(timeout)
logger.debug(f"Input ready: {ready}")
return [event.fd for event, _ in ready]
@abc.abstractmethod
def _read_raw_input(self, timeout: int) -> Iterable[int]: ...
def _get_keyboard_codes(self) -> Iterable[int]:
return self._read_raw_input(0)
def _setup_G1(self) -> None:
"""
Initialize the G1 character set to graphics mode if required.
"""
if self._setup_G1_done:
return
while True:
with contextlib.suppress(OSError):
self.write(escape.DESIGNATE_G1_SPECIAL)
self.flush()
break
self._setup_G1_done = True
def draw_screen(self, size: tuple[int, int], canvas: Canvas) -> None:
"""Paint screen with rendered canvas."""
def set_cursor_home() -> str:
if not partial_display():
return escape.set_cursor_position(0, 0)
return escape.CURSOR_HOME_COL + escape.move_cursor_up(cy)
def set_cursor_position(x: int, y: int) -> str:
if not partial_display():
return escape.set_cursor_position(x, y)
if cy > y:
return "\b" + escape.CURSOR_HOME_COL + escape.move_cursor_up(cy - y) + escape.move_cursor_right(x)
return "\b" + escape.CURSOR_HOME_COL + escape.move_cursor_down(y - cy) + escape.move_cursor_right(x)
def is_blank_row(row: list[tuple[object, Literal["0", "U"] | None], bytes]) -> bool:
if len(row) > 1:
return False
return not row[0][2].strip()
def attr_to_escape(a: AttrSpec | str | None) -> str:
if a in self._pal_escape:
return self._pal_escape[a]
if isinstance(a, AttrSpec):
return self._attrspec_to_escape(a)
if a is None:
return self._attrspec_to_escape(AttrSpec("default", "default"))
# undefined attributes use default/default
self.logger.debug(f"Undefined attribute: {a!r}")
return self._attrspec_to_escape(AttrSpec("default", "default"))
def using_standout_or_underline(a: AttrSpec | str) -> bool:
a = self._pal_attrspec.get(a, a)
return isinstance(a, AttrSpec) and (a.standout or a.underline)
encoding = util.get_encoding()
logger = self.logger.getChild("draw_screen")
(maxcol, maxrow) = size
if not self._started:
raise RuntimeError
if maxrow != canvas.rows():
raise ValueError(maxrow)
# quick return if nothing has changed
if self.screen_buf and canvas is self._screen_buf_canvas:
return
self._setup_G1()
if self._resized:
# handle resize before trying to draw screen
logger.debug("Not drawing screen: screen resized and resize was not handled")
return
logger.debug(f"Drawing screen with size {size!r}")
last_attributes = None # Default = empty
output: list[str] = [escape.HIDE_CURSOR, attr_to_escape(last_attributes)]
def partial_display() -> bool:
# returns True if the screen is in partial display mode ie. only some rows belong to the display
return self._rows_used is not None
if not partial_display():
output.append(escape.CURSOR_HOME)
if self.screen_buf:
osb = self.screen_buf
else:
osb = []
sb: list[list[tuple[object, Literal["0", "U"] | None, bytes]]] = []
cy = self._cy
y = -1
ins = None
output.append(set_cursor_home())
cy = 0
first = True
last_charset_flag = None
for row in canvas.content():
y += 1
if osb and y < len(osb) and osb[y] == row:
# this row of the screen buffer matches what is
# currently displayed, so we can skip this line
sb.append(osb[y])
continue
sb.append(row)
# leave blank lines off display when we are using
# the default screen buffer (allows partial screen)
if partial_display() and y > self._rows_used:
if is_blank_row(row):
continue
self._rows_used = y
if y or partial_display():
output.append(set_cursor_position(0, y))
# after updating the line we will be just over the
# edge, but terminals still treat this as being
# on the same line
cy = y
whitespace_at_end = False
if row:
a, cs, run = row[-1]
if run[-1:] == b" " and self.back_color_erase and not using_standout_or_underline(a):
whitespace_at_end = True
row = [*row[:-1], (a, cs, run.rstrip(b" "))] # noqa: PLW2901
elif y == maxrow - 1 and maxcol > 1:
row, back, ins = self._last_row(row) # noqa: PLW2901
for a, cs, run in row:
if not isinstance(run, bytes): # canvases render with bytes
raise TypeError(run)
if cs != "U":
run = run.translate(UNPRINTABLE_TRANS_TABLE) # noqa: PLW2901
if last_attributes != a:
output.append(attr_to_escape(a))
last_attributes = a
if encoding != "utf-8" and (first or last_charset_flag != cs):
if cs not in {None, "0", "U"}:
raise ValueError(cs)
if last_charset_flag == "U":
output.append(escape.IBMPC_OFF)
if cs is None:
output.append(escape.SI)
elif cs == "U":
output.append(escape.IBMPC_ON)
else:
output.append(escape.SO)
last_charset_flag = cs
output.append(run.decode(encoding, "replace"))
first = False
if ins:
(inserta, insertcs, inserttext) = ins
ias = attr_to_escape(inserta)
if insertcs not in {None, "0", "U"}:
raise ValueError(insertcs)
if isinstance(inserttext, bytes):
inserttext = inserttext.decode(encoding)
output.extend(("\x08" * back, ias)) # pylint: disable=used-before-assignment # defined in `if row`
if encoding != "utf-8":
if cs is None:
icss = escape.SI
elif cs == "U":
icss = escape.IBMPC_ON
else:
icss = escape.SO
output.append(icss)
if not IS_WINDOWS:
output += [escape.INSERT_ON, inserttext, escape.INSERT_OFF]
else:
output += [f"{escape.ESC}[{str_util.calc_width(inserttext, 0, len(inserttext))}@", inserttext]
if encoding != "utf-8" and cs == "U":
output.append(escape.IBMPC_OFF)
if whitespace_at_end:
output.append(escape.ERASE_IN_LINE_RIGHT)
if canvas.cursor is not None:
x, y = canvas.cursor
output += [set_cursor_position(x, y), escape.SHOW_CURSOR]
self._cy = y
if self._resized:
# handle resize before trying to draw screen
return
try:
for line in output:
if isinstance(line, bytes):
line = line.decode(encoding, "replace") # noqa: PLW2901
self.write(line)
self.flush()
except OSError as e:
# ignore interrupted syscall
if e.args[0] != 4:
raise
self.screen_buf = sb
self._screen_buf_canvas = canvas
def _last_row(
self,
row: list[tuple[object, Literal["0", "U"] | None, bytes]],
) -> tuple[
list[tuple[object, Literal["0", "U"] | None, bytes]],
int,
tuple[object, Literal["0", "U"] | None, bytes],
]:
"""On the last row we need to slide the bottom right character
into place. Calculate the new line, attr and an insert sequence
to do that.
eg. last row:
XXXXXXXXXXXXXXXXXXXXYZ
Y will be drawn after Z, shifting Z into position.
"""
new_row = row[:-1]
z_attr, z_cs, last_text = row[-1]
last_cols = str_util.calc_width(last_text, 0, len(last_text))
last_offs, z_col = str_util.calc_text_pos(last_text, 0, len(last_text), last_cols - 1)
if last_offs == 0:
z_text = last_text
del new_row[-1]
# we need another segment
y_attr, y_cs, nlast_text = row[-2]
nlast_cols = str_util.calc_width(nlast_text, 0, len(nlast_text))
z_col += nlast_cols
nlast_offs, y_col = str_util.calc_text_pos(nlast_text, 0, len(nlast_text), nlast_cols - 1)
y_text = nlast_text[nlast_offs:]
if nlast_offs:
new_row.append((y_attr, y_cs, nlast_text[:nlast_offs]))
else:
z_text = last_text[last_offs:]
y_attr, y_cs = z_attr, z_cs
nlast_cols = str_util.calc_width(last_text, 0, last_offs)
nlast_offs, y_col = str_util.calc_text_pos(last_text, 0, last_offs, nlast_cols - 1)
y_text = last_text[nlast_offs:last_offs]
if nlast_offs:
new_row.append((y_attr, y_cs, last_text[:nlast_offs]))
new_row.append((z_attr, z_cs, z_text))
return new_row, z_col - y_col, (y_attr, y_cs, y_text)
def clear(self) -> None:
"""
Force the screen to be completely repainted on the next
call to draw_screen().
"""
self.screen_buf = None
def _attrspec_to_escape(self, a: AttrSpec) -> str:
"""
Convert AttrSpec instance a to an escape sequence for the terminal
>>> s = Screen()
>>> s.set_terminal_properties(colors=256)
>>> a2e = s._attrspec_to_escape
>>> a2e(s.AttrSpec('brown', 'dark green'))
'\\x1b[0;33;42m'
>>> a2e(s.AttrSpec('#fea,underline', '#d0d'))
'\\x1b[0;38;5;229;4;48;5;164m'
"""
if self.term == "fbterm":
fg = escape.ESC + f"[1;{a.foreground_number:d}}}"
bg = escape.ESC + f"[2;{a.background_number:d}}}"
return fg + bg
if a.foreground_true:
fg = f"38;2;{';'.join(str(part) for part in a.get_rgb_values()[0:3])}"
elif a.foreground_high:
fg = f"38;5;{a.foreground_number:d}"
elif a.foreground_basic:
if a.foreground_number > 7:
if self.fg_bright_is_bold:
fg = f"1;{a.foreground_number - 8 + 30:d}"
else:
fg = f"{a.foreground_number - 8 + 90:d}"
else:
fg = f"{a.foreground_number + 30:d}"
else:
fg = "39"
st = (
"1;" * a.bold
+ "3;" * a.italics
+ "4;" * a.underline
+ "5;" * a.blink
+ "7;" * a.standout
+ "9;" * a.strikethrough
)
if a.background_true:
bg = f"48;2;{';'.join(str(part) for part in a.get_rgb_values()[3:6])}"
elif a.background_high:
bg = f"48;5;{a.background_number:d}"
elif a.background_basic:
if a.background_number > 7:
if self.bg_bright_is_blink:
bg = f"5;{a.background_number - 8 + 40:d}"
else:
# this doesn't work on most terminals
bg = f"{a.background_number - 8 + 100:d}"
else:
bg = f"{a.background_number + 40:d}"
else:
bg = "49"
return f"{escape.ESC}[0;{fg};{st}{bg}m"
def set_terminal_properties(
self,
colors: Literal[1, 16, 88, 256, 16777216] | None = None,
bright_is_bold: bool | None = None,
has_underline: bool | None = None,
) -> None:
"""
colors -- number of colors terminal supports (1, 16, 88, 256, or 2**24)
or None to leave unchanged
bright_is_bold -- set to True if this terminal uses the bold
setting to create bright colors (numbers 8-15), set to False
if this Terminal can create bright colors without bold or
None to leave unchanged
has_underline -- set to True if this terminal can use the
underline setting, False if it cannot or None to leave
unchanged
"""
if colors is None:
colors = self.colors
if bright_is_bold is None:
bright_is_bold = self.fg_bright_is_bold
if has_underline is None:
has_underline = self.has_underline
if colors == self.colors and bright_is_bold == self.fg_bright_is_bold and has_underline == self.has_underline:
return
self.colors = colors
self.fg_bright_is_bold = bright_is_bold
self.has_underline = has_underline
self.clear()
self._pal_escape = {}
for p, v in self._palette.items():
self._on_update_palette_entry(p, *v)
def reset_default_terminal_palette(self) -> None:
"""
Attempt to set the terminal palette to default values as taken from xterm.
Uses a number of colors from the current set_terminal_properties() screen setting.
"""
if self.colors == 1:
return
if self.colors == 2**24:
colors = 256
else:
colors = self.colors
def rgb_values(n) -> tuple[int | None, int | None, int | None]:
if colors == 16:
aspec = AttrSpec(f"h{n:d}", "", 256)
else:
aspec = AttrSpec(f"h{n:d}", "", colors)
return aspec.get_rgb_values()[:3]
entries = [(n, *rgb_values(n)) for n in range(min(colors, 256))]
self.modify_terminal_palette(entries)
def modify_terminal_palette(self, entries: list[tuple[int, int | None, int | None, int | None]]):
"""
entries - list of (index, red, green, blue) tuples.
Attempt to set part of the terminal palette (this does not work on all terminals.)
The changes are sent as a single escape sequence so they should all take effect at the same time.
0 <= index < 256 (some terminals will only have 16 or 88 colors)
0 <= red, green, blue < 256
"""
if self.term == "fbterm":
modify = [f"{index:d};{red:d};{green:d};{blue:d}" for index, red, green, blue in entries]
self.write(f"\x1b[3;{';'.join(modify)}}}")
else:
modify = [f"{index:d};rgb:{red:02x}/{green:02x}/{blue:02x}" for index, red, green, blue in entries]
self.write(f"\x1b]4;{';'.join(modify)}\x1b\\")
self.flush()
# shortcut for creating an AttrSpec with this screen object's
# number of colors
def AttrSpec(self, fg, bg) -> AttrSpec:
return AttrSpec(fg, bg, self.colors)
@@ -0,0 +1,12 @@
body { margin: 8px 8px 8px 8px; border: 0;
color: black; background-color: silver;
font-family: fixed; overflow: hidden; }
form { margin: 0 0 8px 0; }
#text { position: relative;
background-color: silver;
width: 100%; height: 100%;
margin: 3px 0 0 0; border: 1px solid #999; }
#page { position: relative; width: 100%;height: 100%;}
@@ -0,0 +1,462 @@
// Urwid web (CGI/Asynchronous Javascript) display module
// Copyright (C) 2004-2005 Ian Ward
//
// This library is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
// License as published by the Free Software Foundation; either
// version 2.1 of the License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
//
// Urwid web site: https://urwid.org/
colours = new Object();
colours = {
'0': "black",
'1': "#c00000",
'2': "green",
'3': "#804000",
'4': "#0000c0",
'5': "#c000c0",
'6': "teal",
'7': "silver",
'8': "gray",
'9': "#ff6060",
'A': "lime",
'B': "yellow",
'C': "#8080ff",
'D': "#ff40ff",
'E': "aqua",
'F': "white"
};
keycodes = new Object();
keycodes = {
8: "backspace", 9: "tab", 13: "enter", 27: "esc",
33: "page up", 34: "page down", 35: "end", 36: "home",
37: "left", 38: "up", 39: "right", 40: "down",
45: "insert", 46: "delete",
112: "f1", 113: "f2", 114: "f3", 115: "f4",
116: "f5", 117: "f6", 118: "f7", 119: "f8",
120: "f9", 121: "f10", 122: "f11", 123: "f12"
};
var conn = null;
var char_width = null;
var char_height = null;
var screen_x = null;
var screen_y = null;
var urwid_id = null;
var send_conn = null;
var send_queue_max = 32;
var send_queue = new Array(send_queue_max);
var send_queue_in = 0;
var send_queue_out = 0;
var check_font_delay = 1000;
var send_more_delay = 100;
var poll_again_delay = 500;
var document_location = null;
var update_method = "multipart";
var sending = false;
var lastkeydown = null;
function setup_connection() {
if (window.XMLHttpRequest) {
conn = new XMLHttpRequest();
} else if (window.ActiveXObject) {
conn = new ActiveXObject("Microsoft.XMLHTTP");
}
if (conn == null) {
set_status("Connection Failed");
alert( "Can't figure out how to send request." );
return;
}
try{
conn.multipart = true;
}catch(e){
update_method = "polling";
}
conn.onreadystatechange = handle_recv;
conn.open("POST", document_location, true);
conn.setRequestHeader("X-Urwid-Method",update_method);
conn.setRequestHeader("Content-type","text/plain");
conn.send("window resize " +screen_x+" "+screen_y+"\n");
}
function do_poll() {
if (urwid_id == null){
alert("that's unpossible!");
return;
}
if (window.XMLHttpRequest) {
conn = new XMLHttpRequest();
} else if (window.ActiveXObject) {
conn = new ActiveXObject("Microsoft.XMLHTTP");
}
conn.onreadystatechange = handle_recv;
conn.open("POST", document_location, true);
conn.setRequestHeader("X-Urwid-Method","polling");
conn.setRequestHeader("X-Urwid-ID",urwid_id);
conn.setRequestHeader("Content-type","text/plain");
conn.send("eh?");
}
function handle_recv() {
if( ! conn ){ return;}
if( conn.readyState != 4) {
return;
}
if( conn.status == 404 && urwid_id != null) {
set_status("Connection Closed");
return;
}
if( conn.status == 403 && update_method == "polling" ) {
set_status("Server Refused Connection");
alert("This server does not allow polling clients.\n\n" +
"Please use a web browser with multipart support " +
"such as Mozilla Firefox");
return;
}
if( conn.status == 503 ) {
set_status("Connection Failed");
alert("The server has reached its maximum number of "+
"connections.\n\nPlease try again later.");
return;
}
if( conn.status != 200) {
set_status("Connection Failed");
alert("Error from server: "+conn.statusText);
return;
}
if( urwid_id == null ){
urwid_id = conn.getResponseHeader("X-Urwid-ID");
if( send_queue_in != send_queue_out ){
// keys waiting
do_send();
}
if(update_method=="polling"){
set_status("Polling");
}else if(update_method=="multipart"){
set_status("Connected");
}
}
if( conn.responseText == "" ){
if(update_method=="polling"){
poll_again();
}
return; // keepalive
}
if( conn.responseText == "Z" ){
set_status("Connection Closed");
update_method = null;
return;
}
var text = document.getElementById('text');
var last_screen = Array(text.childNodes.length);
for( var i=0; i<text.childNodes.length; i++ ){
last_screen[i] = text.childNodes[i];
}
var frags = conn.responseText.split("\n");
var ln = document.createElement('span');
var k = 0;
for( var i=0; i<frags.length; i++ ){
var f = frags[i];
if( f == "" ){
var br = document.getElementById('br').cloneNode(true);
ln.appendChild( br );
if( text.childNodes.length > k ){
text.replaceChild(ln, text.childNodes[k]);
}else{
text.appendChild(ln);
}
k = k+1;
ln = document.createElement('span');
}else if( f.charAt(0) == "<" ){
line_number = parseInt(f.substr(1));
if( line_number == k ){
k = k +1;
continue;
}
var clone = last_screen[line_number].cloneNode(true);
if( text.childNodes.length > k ){
text.replaceChild(clone, text.childNodes[k]);
}else{
text.appendChild(clone);
}
k = k+1;
}else{
var span=make_span(f.substr(2),f.charAt(0),f.charAt(1));
ln.appendChild( span );
}
}
for( var i=k; i < text.childNodes.length; i++ ){
text.removeChild(last_screen[i]);
}
if(update_method=="polling"){
poll_again();
}
}
function poll_again(){
if(conn.status == 200){
setTimeout("do_poll();",poll_again_delay);
}
}
function load_web_display(){
if( document.documentURI ){
document_location = document.documentURI;
}else{
document_location = document.location;
}
document.onkeypress = body_keypress;
document.onkeydown = body_keydown;
document.onresize = body_resize;
body_resize();
send_queue_out = send_queue_in; // don't queue the first resize
set_status("Connecting");
setup_connection();
setTimeout("check_fontsize();",check_font_delay);
}
function set_status( status ){
var s = document.getElementById('status');
var t = document.createTextNode(status);
s.replaceChild(t, s.firstChild);
}
function make_span(s, fg, bg){
d = document.createElement('span');
d.style.backgroundColor = colours[bg];
d.style.color = colours[fg];
d.appendChild(document.createTextNode(s));
return d;
}
function body_keydown(e){
if (conn == null){
return;
}
if (!e) var e = window.event;
if (e.keyCode) code = e.keyCode;
else if (e.which) code = e.which;
var mod = "";
var key;
if( e.ctrlKey ){ mod = "ctrl " + mod; }
if( e.altKey || e.metaKey ){ mod = "meta " + mod; }
if( e.shiftKey && e.charCode == 0 ){ mod = "shift " + mod; }
key = keycodes[code];
if( key != undefined ){
lastkeydown = key;
send_key( mod + key );
stop_key_event(e);
return false;
}
}
function body_keypress(e){
if (conn == null){
return;
}
if (!e) var e = window.event;
if (e.keyCode) code = e.keyCode;
else if (e.which) code = e.which;
var mod = "";
var key;
if( e.ctrlKey ){ mod = "ctrl " + mod; }
if( e.altKey || e.metaKey ){ mod = "meta " + mod; }
if( e.shiftKey && e.charCode == 0 ){ mod = "shift " + mod; }
if( e.charCode != null && e.charCode != 0 ){
key = String.fromCharCode(e.charCode);
}else if( e.charCode == null ){
key = String.fromCharCode(code);
}else{
key = keycodes[code];
if( key == undefined || lastkeydown == key ){
lastkeydown = null;
stop_key_event(e);
return false;
}
}
send_key( mod + key );
stop_key_event(e);
return false;
}
function stop_key_event(e){
e.cancelBubble = true;
if( e.stopPropagation ){
e.stopPropagation();
}
if( e.preventDefault ){
e.preventDefault();
}
}
function send_key( key ){
if( (send_queue_in+1)%send_queue_max == send_queue_out ){
// buffer overrun
return;
}
send_queue[send_queue_in] = key;
send_queue_in = (send_queue_in+1)%send_queue_max;
if( urwid_id != null ){
if (send_conn == undefined || send_conn.ready_state != 4 ){
send_more();
return;
}
do_send();
}
}
function do_send() {
if( ! urwid_id ){ return; }
if( ! update_method ){ return; } // connection closed
if( send_queue_in == send_queue_out ){ return; }
if( sending ){
//var queue_delta = send_queue_in - send_queue_out;
//if( queue_delta < 0 ){ queue_delta += send_queue_max; }
//set_status("Sending (queued "+queue_delta+")");
return;
}
try{
sending = true;
//set_status("starting send");
if( send_conn == null ){
if (window.XMLHttpRequest) {
send_conn = new XMLHttpRequest();
} else if (window.ActiveXObject) {
send_conn = new ActiveXObject("Microsoft.XMLHTTP");
}
}else if( send_conn.status != 200) {
alert("Error from server: "+send_conn.statusText);
return;
}else if(send_conn.readyState != 4 ){
alert("not ready on send connection");
return;
}
} catch(e) {
alert(e);
sending = false;
return;
}
send_conn.open("POST", document_location, true);
send_conn.onreadystatechange = send_handle_recv;
send_conn.setRequestHeader("Content-type","text/plain");
send_conn.setRequestHeader("X-Urwid-ID",urwid_id);
var tmp_send_queue_in = send_queue_in;
var out = null;
if( send_queue_out > tmp_send_queue_in ){
out = send_queue.slice(send_queue_out).join("\n")
if( tmp_send_queue_in > 0 ){
out += "\n" + send_queue.slice(0,tmp_send_queue_in).join("\n");
}
}else{
out = send_queue.slice(send_queue_out,
tmp_send_queue_in).join("\n");
}
send_queue_out = tmp_send_queue_in;
//set_status("Sending");
send_conn.send( out +"\n" );
}
function send_handle_recv() {
if( send_conn.readyState != 4) {
return;
}
if( send_conn.status == 404) {
set_status("Connection Closed");
update_method = null;
return;
}
if( send_conn.status != 200) {
alert("Error from server: "+send_conn.statusText);
return;
}
sending = false;
if( send_queue_out != send_queue_in ){
send_more();
}
}
function send_more(){
setTimeout("do_send();",send_more_delay);
}
function check_fontsize(){
body_resize()
setTimeout("check_fontsize();",check_font_delay);
}
function body_resize(){
var t = document.getElementById('testchar');
var t2 = document.getElementById('testchar2');
var text = document.getElementById('text');
var window_width;
var window_height;
if (window.innerHeight) {
window_width = window.innerWidth;
window_height = window.innerHeight;
}else{
window_width = document.documentElement.clientWidth;
window_height = document.documentElement.clientHeight;
//var z = "CI:"; for(var i in bod){z = z + " " + i;} alert(z);
}
char_width = t.offsetLeft / 44;
var avail_width = window_width-18;
var avail_width_mod = avail_width % char_width;
var x_size = (avail_width - avail_width_mod)/char_width;
char_height = t2.offsetTop - t.offsetTop;
var avail_height = window_height-text.offsetTop-10;
var avail_height_mod = avail_height % char_height;
var y_size = (avail_height - avail_height_mod)/char_height;
text.style.width = x_size*char_width+"px";
text.style.height = y_size*char_height+"px";
if( screen_x != x_size || screen_y != y_size ){
send_key("window resize "+x_size+" "+y_size);
}
screen_x = x_size;
screen_y = y_size;
}
@@ -0,0 +1,171 @@
from __future__ import annotations
import enum
import typing
from ctypes import POINTER, Structure, Union, windll
from ctypes.wintypes import BOOL, CHAR, DWORD, HANDLE, LPDWORD, SHORT, UINT, WCHAR, WORD
# https://docs.microsoft.com/de-de/windows/console/getstdhandle
STD_INPUT_HANDLE = -10
STD_OUTPUT_HANDLE = -11
# https://docs.microsoft.com/de-de/windows/console/setconsolemode
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
DISABLE_NEWLINE_AUTO_RETURN = 0x0008
ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200
ENABLE_WINDOW_INPUT = 0x0008
class COORD(Structure):
"""https://docs.microsoft.com/en-us/windows/console/coord-str"""
_fields_: typing.ClassVar[list[tuple[str, type]]] = [
("X", SHORT),
("Y", SHORT),
]
class SMALL_RECT(Structure):
"""https://docs.microsoft.com/en-us/windows/console/small-rect-str"""
_fields_: typing.ClassVar[list[tuple[str, type]]] = [
("Left", SHORT),
("Top", SHORT),
("Right", SHORT),
("Bottom", SHORT),
]
class CONSOLE_SCREEN_BUFFER_INFO(Structure):
"""https://docs.microsoft.com/en-us/windows/console/console-screen-buffer-info-str"""
_fields_: typing.ClassVar[list[tuple[str, type]]] = [
("dwSize", COORD),
("dwCursorPosition", COORD),
("wAttributes", WORD),
("srWindow", SMALL_RECT),
("dwMaximumWindowSize", COORD),
]
class uChar(Union):
"""https://docs.microsoft.com/en-us/windows/console/key-event-record-str"""
_fields_: typing.ClassVar[list[tuple[str, type]]] = [
("AsciiChar", CHAR),
("UnicodeChar", WCHAR),
]
class KEY_EVENT_RECORD(Structure):
"""https://docs.microsoft.com/en-us/windows/console/key-event-record-str"""
_fields_: typing.ClassVar[list[tuple[str, type]]] = [
("bKeyDown", BOOL),
("wRepeatCount", WORD),
("wVirtualKeyCode", WORD),
("wVirtualScanCode", WORD),
("uChar", uChar),
("dwControlKeyState", DWORD),
]
class MOUSE_EVENT_RECORD(Structure):
"""https://docs.microsoft.com/en-us/windows/console/mouse-event-record-str"""
_fields_: typing.ClassVar[list[tuple[str, type]]] = [
("dwMousePosition", COORD),
("dwButtonState", DWORD),
("dwControlKeyState", DWORD),
("dwEventFlags", DWORD),
]
class MouseButtonState(enum.IntFlag):
"""https://learn.microsoft.com/en-us/windows/console/mouse-event-record-str"""
FROM_LEFT_1ST_BUTTON_PRESSED = 0x0001
RIGHTMOST_BUTTON_PRESSED = 0x0002
FROM_LEFT_2ND_BUTTON_PRESSED = 0x0004
FROM_LEFT_3RD_BUTTON_PRESSED = 0x0008
FROM_LEFT_4TH_BUTTON_PRESSED = 0x0010
class MouseEventFlags(enum.IntFlag):
"""https://learn.microsoft.com/en-us/windows/console/mouse-event-record-str"""
BUTTON_PRESSED = 0x0000 # Default action, used in examples, but not in official enum
MOUSE_MOVED = 0x0001
DOUBLE_CLICK = 0x0002
MOUSE_WHEELED = 0x0004
MOUSE_HWHEELED = 0x0008
class WINDOW_BUFFER_SIZE_RECORD(Structure):
"""https://docs.microsoft.com/en-us/windows/console/window-buffer-size-record-str"""
_fields_: typing.ClassVar[list[tuple[str, type]]] = [("dwSize", COORD)]
class MENU_EVENT_RECORD(Structure):
"""https://docs.microsoft.com/en-us/windows/console/menu-event-record-str"""
_fields_: typing.ClassVar[list[tuple[str, type]]] = [("dwCommandId", UINT)]
class FOCUS_EVENT_RECORD(Structure):
"""https://docs.microsoft.com/en-us/windows/console/focus-event-record-str"""
_fields_: typing.ClassVar[list[tuple[str, type]]] = [("bSetFocus", BOOL)]
class Event(Union):
"""https://docs.microsoft.com/en-us/windows/console/input-record-str"""
_fields_: typing.ClassVar[list[tuple[str, type]]] = [
("KeyEvent", KEY_EVENT_RECORD),
("MouseEvent", MOUSE_EVENT_RECORD),
("WindowBufferSizeEvent", WINDOW_BUFFER_SIZE_RECORD),
("MenuEvent", MENU_EVENT_RECORD),
("FocusEvent", FOCUS_EVENT_RECORD),
]
class INPUT_RECORD(Structure):
"""https://docs.microsoft.com/en-us/windows/console/input-record-str"""
_fields_: typing.ClassVar[list[tuple[str, type]]] = [("EventType", WORD), ("Event", Event)]
class EventType(enum.IntFlag):
KEY_EVENT = 0x0001
MOUSE_EVENT = 0x0002
WINDOW_BUFFER_SIZE_EVENT = 0x0004
MENU_EVENT = 0x0008
FOCUS_EVENT = 0x0010
# https://docs.microsoft.com/de-de/windows/console/getstdhandle
GetStdHandle = windll.kernel32.GetStdHandle
GetStdHandle.argtypes = [DWORD]
GetStdHandle.restype = HANDLE
# https://docs.microsoft.com/de-de/windows/console/getconsolemode
GetConsoleMode = windll.kernel32.GetConsoleMode
GetConsoleMode.argtypes = [HANDLE, LPDWORD]
GetConsoleMode.restype = BOOL
# https://docs.microsoft.com/de-de/windows/console/setconsolemode
SetConsoleMode = windll.kernel32.SetConsoleMode
SetConsoleMode.argtypes = [HANDLE, DWORD]
SetConsoleMode.restype = BOOL
# https://docs.microsoft.com/de-de/windows/console/readconsoleinput
ReadConsoleInputW = windll.kernel32.ReadConsoleInputW
# ReadConsoleInputW.argtypes = [HANDLE, POINTER(INPUT_RECORD), DWORD, LPDWORD]
ReadConsoleInputW.restype = BOOL
# https://docs.microsoft.com/en-us/windows/console/getconsolescreenbufferinfo
GetConsoleScreenBufferInfo = windll.kernel32.GetConsoleScreenBufferInfo
GetConsoleScreenBufferInfo.argtypes = [HANDLE, POINTER(CONSOLE_SCREEN_BUFFER_INFO)]
GetConsoleScreenBufferInfo.restype = BOOL
@@ -0,0 +1,267 @@
# Urwid raw display module
# Copyright (C) 2004-2009 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
"""
Direct terminal UI implementation
"""
from __future__ import annotations
import contextlib
import functools
import logging
import selectors
import socket
import sys
import threading
import typing
from ctypes import byref
from ctypes.wintypes import DWORD
from urwid import signals
from . import _raw_display_base, _win32, escape
from .common import INPUT_DESCRIPTORS_CHANGED
if typing.TYPE_CHECKING:
from collections.abc import Callable
from urwid.event_loop import EventLoop
class Screen(_raw_display_base.Screen):
_term_input_file: socket.socket
def __init__(
self,
input: socket.socket | None = None, # noqa: A002 # pylint: disable=redefined-builtin
output: typing.TextIO = sys.stdout,
) -> None:
"""Initialize a screen that directly prints escape codes to an output
terminal.
"""
if input is None:
input, self._send_input = socket.socketpair() # noqa: A001
super().__init__(input, output)
_dwOriginalOutMode = None
_dwOriginalInMode = None
def _start(self, alternate_buffer: bool = True) -> None:
"""
Initialize the screen and input mode.
alternate_buffer -- use alternate screen buffer
"""
if alternate_buffer:
self.write(escape.SWITCH_TO_ALTERNATE_BUFFER)
self._rows_used = None
else:
self._rows_used = 0
handle_out = _win32.GetStdHandle(_win32.STD_OUTPUT_HANDLE)
handle_in = _win32.GetStdHandle(_win32.STD_INPUT_HANDLE)
self._dwOriginalOutMode = DWORD()
self._dwOriginalInMode = DWORD()
_win32.GetConsoleMode(handle_out, byref(self._dwOriginalOutMode))
_win32.GetConsoleMode(handle_in, byref(self._dwOriginalInMode))
# TODO: Restore on exit
dword_out_mode = DWORD(
self._dwOriginalOutMode.value
| _win32.ENABLE_VIRTUAL_TERMINAL_PROCESSING
| _win32.DISABLE_NEWLINE_AUTO_RETURN
)
dword_in_mode = DWORD(
self._dwOriginalInMode.value | _win32.ENABLE_WINDOW_INPUT | _win32.ENABLE_VIRTUAL_TERMINAL_INPUT
)
if not (ok := _win32.SetConsoleMode(handle_out, dword_out_mode)):
raise RuntimeError(f"ConsoleMode set failed for output. Err: {ok!r}")
if not (ok := _win32.SetConsoleMode(handle_in, dword_in_mode)):
raise RuntimeError(f"ConsoleMode set failed for input. Err: {ok!r}")
self._alternate_buffer = alternate_buffer
self._next_timeout = self.max_wait
signals.emit_signal(self, INPUT_DESCRIPTORS_CHANGED)
# restore mouse tracking to previous state
self._mouse_tracking(self._mouse_tracking_enabled)
return super()._start()
def _stop(self) -> None:
"""
Restore the screen.
"""
self.clear()
signals.emit_signal(self, INPUT_DESCRIPTORS_CHANGED)
self._stop_mouse_restore_buffer()
handle_out = _win32.GetStdHandle(_win32.STD_OUTPUT_HANDLE)
handle_in = _win32.GetStdHandle(_win32.STD_INPUT_HANDLE)
if not (ok := _win32.SetConsoleMode(handle_out, self._dwOriginalOutMode)):
raise RuntimeError(f"ConsoleMode set failed for output. Err: {ok!r}")
if not (ok := _win32.SetConsoleMode(handle_in, self._dwOriginalInMode)):
raise RuntimeError(f"ConsoleMode set failed for input. Err: {ok!r}")
super()._stop()
def unhook_event_loop(self, event_loop: EventLoop) -> None:
"""
Remove any hooks added by hook_event_loop.
"""
if self._input_thread is not None:
self._input_thread.should_exit = True
with contextlib.suppress(RuntimeError):
self._input_thread.join(5)
self._input_thread = None
for handle in self._current_event_loop_handles:
event_loop.remove_watch_file(handle)
if self._input_timeout:
event_loop.remove_alarm(self._input_timeout)
self._input_timeout = None
def hook_event_loop(
self,
event_loop: EventLoop,
callback: Callable[[list[str], list[int]], typing.Any],
) -> None:
"""
Register the given callback with the event loop, to be called with new
input whenever it's available. The callback should be passed a list of
processed keys and a list of unprocessed keycodes.
Subclasses may wish to use parse_input to wrap the callback.
"""
self._input_thread = ReadInputThread(self._send_input, lambda: self._sigwinch_handler(28))
self._input_thread.start()
if hasattr(self, "get_input_nonblocking"):
wrapper = self._make_legacy_input_wrapper(event_loop, callback)
else:
@functools.wraps(callback)
def wrapper() -> tuple[list[str], typing.Any] | None:
return self.parse_input(event_loop, callback, self.get_available_raw_input())
fds = self.get_input_descriptors()
handles = [event_loop.watch_file(fd if isinstance(fd, int) else fd.fileno(), wrapper) for fd in fds]
self._current_event_loop_handles = handles
_input_thread: ReadInputThread | None = None
def _read_raw_input(self, timeout: int) -> bytearray:
ready = self._wait_for_input_ready(timeout)
fd = self._input_fileno()
chars = bytearray()
if fd is None or fd not in ready:
return chars
with selectors.DefaultSelector() as selector:
selector.register(fd, selectors.EVENT_READ)
while selector.select(0):
chars.extend(self._term_input_file.recv(1024))
return chars
def get_cols_rows(self) -> tuple[int, int]:
"""Return the terminal dimensions (num columns, num rows)."""
y, x = super().get_cols_rows()
with contextlib.suppress(OSError): # Term size could not be determined
if hasattr(self._term_output_file, "fileno"):
if self._term_output_file != sys.stdout:
raise RuntimeError("Unexpected terminal output file")
handle = _win32.GetStdHandle(_win32.STD_OUTPUT_HANDLE)
info = _win32.CONSOLE_SCREEN_BUFFER_INFO()
if _win32.GetConsoleScreenBufferInfo(handle, byref(info)):
# Fallback will be used in case of term size could not be determined
y, x = info.dwSize.Y, info.dwSize.X
self.maxrow = y
return x, y
class ReadInputThread(threading.Thread):
name = "urwid Windows input reader"
daemon = True
should_exit: bool = False
def __init__(
self,
input_socket: socket.socket,
resize: Callable[[], typing.Any],
) -> None:
self._input = input_socket
self._resize = resize
self.logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
super().__init__()
def run(self) -> None:
hIn = _win32.GetStdHandle(_win32.STD_INPUT_HANDLE)
MAX = 2048
read = DWORD(0)
arrtype = _win32.INPUT_RECORD * MAX
input_records = arrtype()
while True:
_win32.ReadConsoleInputW(hIn, byref(input_records), MAX, byref(read))
if self.should_exit:
return
for i in range(read.value):
inp = input_records[i]
if inp.EventType == _win32.EventType.KEY_EVENT:
if not inp.Event.KeyEvent.bKeyDown:
continue
input_data = inp.Event.KeyEvent.uChar.UnicodeChar
# On Windows atomic press/release of modifier keys produce phantom input with code NULL.
# This input cannot be decoded and should be handled as garbage.
input_bytes = input_data.encode("utf-8")
if input_bytes != b"\x00":
self._input.send(input_bytes)
elif inp.EventType == _win32.EventType.WINDOW_BUFFER_SIZE_EVENT:
self._resize()
else:
pass # TODO: handle mouse events
def _test():
import doctest
doctest.testmod()
if __name__ == "__main__":
_test()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,690 @@
# Urwid curses output wrapper.. the horror..
# Copyright (C) 2004-2011 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
"""
Curses-based UI implementation
"""
from __future__ import annotations
import curses
import sys
import typing
from contextlib import suppress
from urwid import util
from . import escape
from .common import UNPRINTABLE_TRANS_TABLE, AttrSpec, BaseScreen, RealTerminal
if typing.TYPE_CHECKING:
from typing_extensions import Literal
from urwid import Canvas
IS_WINDOWS = sys.platform == "win32"
# curses.KEY_RESIZE (sometimes not defined)
if IS_WINDOWS:
KEY_MOUSE = 539 # under Windows key mouse is different
KEY_RESIZE = 546
COLOR_CORRECTION: dict[int, int] = dict(
enumerate(
(
curses.COLOR_BLACK,
curses.COLOR_RED,
curses.COLOR_GREEN,
curses.COLOR_YELLOW,
curses.COLOR_BLUE,
curses.COLOR_MAGENTA,
curses.COLOR_CYAN,
curses.COLOR_WHITE,
)
)
)
def initscr():
import curses # noqa: I001 # pylint: disable=redefined-outer-name,reimported # special case for monkeypatch
import _curses
stdscr = _curses.initscr()
for key, value in _curses.__dict__.items():
if key[:4] == "ACS_" or key in {"LINES", "COLS"}:
setattr(curses, key, value)
return stdscr
curses.initscr = initscr
else:
KEY_MOUSE = 409 # curses.KEY_MOUSE
KEY_RESIZE = 410
COLOR_CORRECTION = {}
_curses_colours = { # pylint: disable=consider-using-namedtuple-or-dataclass # historic test/debug data
"default": (-1, 0),
"black": (curses.COLOR_BLACK, 0),
"dark red": (curses.COLOR_RED, 0),
"dark green": (curses.COLOR_GREEN, 0),
"brown": (curses.COLOR_YELLOW, 0),
"dark blue": (curses.COLOR_BLUE, 0),
"dark magenta": (curses.COLOR_MAGENTA, 0),
"dark cyan": (curses.COLOR_CYAN, 0),
"light gray": (curses.COLOR_WHITE, 0),
"dark gray": (curses.COLOR_BLACK, 1),
"light red": (curses.COLOR_RED, 1),
"light green": (curses.COLOR_GREEN, 1),
"yellow": (curses.COLOR_YELLOW, 1),
"light blue": (curses.COLOR_BLUE, 1),
"light magenta": (curses.COLOR_MAGENTA, 1),
"light cyan": (curses.COLOR_CYAN, 1),
"white": (curses.COLOR_WHITE, 1),
}
class Screen(BaseScreen, RealTerminal):
def __init__(self) -> None:
super().__init__()
self.curses_pairs = [(None, None)] # Can't be sure what pair 0 will default to
self.palette = {}
self.has_color = False
self.s = None
self.cursor_state = None
self.prev_input_resize = 0
self.set_input_timeouts()
self.last_bstate = 0
self._mouse_tracking_enabled = False
self.register_palette_entry(None, "default", "default")
def set_mouse_tracking(self, enable: bool = True) -> None:
"""
Enable mouse tracking.
After calling this function get_input will include mouse
click events along with keystrokes.
"""
enable = bool(enable)
if enable == self._mouse_tracking_enabled:
return
if enable:
curses.mousemask(
0
| curses.BUTTON1_PRESSED
| curses.BUTTON1_RELEASED
| curses.BUTTON2_PRESSED
| curses.BUTTON2_RELEASED
| curses.BUTTON3_PRESSED
| curses.BUTTON3_RELEASED
| curses.BUTTON4_PRESSED
| curses.BUTTON4_RELEASED
| curses.BUTTON1_DOUBLE_CLICKED
| curses.BUTTON1_TRIPLE_CLICKED
| curses.BUTTON2_DOUBLE_CLICKED
| curses.BUTTON2_TRIPLE_CLICKED
| curses.BUTTON3_DOUBLE_CLICKED
| curses.BUTTON3_TRIPLE_CLICKED
| curses.BUTTON4_DOUBLE_CLICKED
| curses.BUTTON4_TRIPLE_CLICKED
| curses.BUTTON_SHIFT
| curses.BUTTON_ALT
| curses.BUTTON_CTRL
)
else:
raise NotImplementedError()
self._mouse_tracking_enabled = enable
def _start(self) -> None:
"""
Initialize the screen and input mode.
"""
self.s = curses.initscr()
self.has_color = curses.has_colors()
if self.has_color:
curses.start_color()
if curses.COLORS < 8:
# not colourful enough
self.has_color = False
if self.has_color:
try:
curses.use_default_colors()
self.has_default_colors = True
except curses.error:
self.has_default_colors = False
self._setup_colour_pairs()
curses.noecho()
curses.meta(True)
curses.halfdelay(10) # use set_input_timeouts to adjust
self.s.keypad(False)
if not self._signal_keys_set:
self._old_signal_keys = self.tty_signal_keys()
super()._start()
if IS_WINDOWS:
# halfdelay() seems unnecessary and causes everything to slow down a lot.
curses.nocbreak() # exits halfdelay mode
# keypad(1) is needed, or we get no special keys (cursor keys, etc.)
self.s.keypad(True)
def _stop(self) -> None:
"""
Restore the screen.
"""
curses.echo()
self._curs_set(1)
with suppress(curses.error):
curses.endwin()
# don't block original error with curses error
if self._old_signal_keys:
self.tty_signal_keys(*self._old_signal_keys)
super()._stop()
def _setup_colour_pairs(self) -> None:
"""
Initialize all 63 color pairs based on the term:
bg * 8 + 7 - fg
So to get a color, we just need to use that term and get the right color
pair number.
"""
if not self.has_color:
return
if IS_WINDOWS:
self.has_default_colors = False
for fg in range(8):
for bg in range(8):
# leave out white on black
if fg == curses.COLOR_WHITE and bg == curses.COLOR_BLACK:
continue
curses.init_pair(bg * 8 + 7 - fg, COLOR_CORRECTION.get(fg, fg), COLOR_CORRECTION.get(bg, bg))
def _curs_set(self, x: int):
if self.cursor_state in {"fixed", x}:
return
try:
curses.curs_set(x)
self.cursor_state = x
except curses.error:
self.cursor_state = "fixed"
def _clear(self) -> None:
self.s.clear()
self.s.refresh()
def _getch(self, wait_tenths: int | None) -> int:
if wait_tenths == 0:
return self._getch_nodelay()
if not IS_WINDOWS:
if wait_tenths is None:
curses.cbreak()
else:
curses.halfdelay(wait_tenths)
self.s.nodelay(False)
return self.s.getch()
def _getch_nodelay(self) -> int:
self.s.nodelay(True)
if not IS_WINDOWS:
while True:
# this call fails sometimes, but seems to work when I try again
with suppress(curses.error):
curses.cbreak()
break
return self.s.getch()
def set_input_timeouts(
self,
max_wait: float | None = None,
complete_wait: float = 0.1,
resize_wait: float = 0.1,
):
"""
Set the get_input timeout values. All values have a granularity
of 0.1s, ie. any value between 0.15 and 0.05 will be treated as
0.1 and any value less than 0.05 will be treated as 0. The
maximum timeout value for this module is 25.5 seconds.
max_wait -- amount of time in seconds to wait for input when
there is no input pending, wait forever if None
complete_wait -- amount of time in seconds to wait when
get_input detects an incomplete escape sequence at the
end of the available input
resize_wait -- amount of time in seconds to wait for more input
after receiving two screen resize requests in a row to
stop urwid from consuming 100% cpu during a gradual
window resize operation
"""
def convert_to_tenths(s):
if s is None:
return None
return int((s + 0.05) * 10)
self.max_tenths = convert_to_tenths(max_wait)
self.complete_tenths = convert_to_tenths(complete_wait)
self.resize_tenths = convert_to_tenths(resize_wait)
@typing.overload
def get_input(self, raw_keys: Literal[False]) -> list[str]: ...
@typing.overload
def get_input(self, raw_keys: Literal[True]) -> tuple[list[str], list[int]]: ...
def get_input(self, raw_keys: bool = False) -> list[str] | tuple[list[str], list[int]]:
"""Return pending input as a list.
raw_keys -- return raw keycodes as well as translated versions
This function will immediately return all the input since the
last time it was called. If there is no input pending it will
wait before returning an empty list. The wait time may be
configured with the set_input_timeouts function.
If raw_keys is False (default) this function will return a list
of keys pressed. If raw_keys is True this function will return
a ( keys pressed, raw keycodes ) tuple instead.
Examples of keys returned:
* ASCII printable characters: " ", "a", "0", "A", "-", "/"
* ASCII control characters: "tab", "enter"
* Escape sequences: "up", "page up", "home", "insert", "f1"
* Key combinations: "shift f1", "meta a", "ctrl b"
* Window events: "window resize"
When a narrow encoding is not enabled:
* "Extended ASCII" characters: "\\xa1", "\\xb2", "\\xfe"
When a wide encoding is enabled:
* Double-byte characters: "\\xa1\\xea", "\\xb2\\xd4"
When utf8 encoding is enabled:
* Unicode characters: u"\\u00a5", u'\\u253c"
Examples of mouse events returned:
* Mouse button press: ('mouse press', 1, 15, 13),
('meta mouse press', 2, 17, 23)
* Mouse button release: ('mouse release', 0, 18, 13),
('ctrl mouse release', 0, 17, 23)
"""
if not self._started:
raise RuntimeError
keys, raw = self._get_input(self.max_tenths)
# Avoid pegging CPU at 100% when slowly resizing, and work
# around a bug with some braindead curses implementations that
# return "no key" between "window resize" commands
if keys == ["window resize"] and self.prev_input_resize:
for _ in range(2):
new_keys, new_raw = self._get_input(self.resize_tenths)
raw += new_raw
if new_keys and new_keys != ["window resize"]:
if "window resize" in new_keys:
keys = new_keys
else:
keys.extend(new_keys)
break
if keys == ["window resize"]:
self.prev_input_resize = 2
elif self.prev_input_resize == 2 and not keys:
self.prev_input_resize = 1
else:
self.prev_input_resize = 0
if raw_keys:
return keys, raw
return keys
def _get_input(self, wait_tenths: int | None) -> tuple[list[str], list[int]]:
# this works around a strange curses bug with window resizing
# not being reported correctly with repeated calls to this
# function without a doupdate call in between
curses.doupdate()
key = self._getch(wait_tenths)
resize = False
raw = []
keys = []
while key >= 0:
raw.append(key)
if key == KEY_RESIZE:
resize = True
elif key == KEY_MOUSE:
keys += self._encode_mouse_event()
else:
keys.append(key)
key = self._getch_nodelay()
processed = []
try:
while keys:
run, keys = escape.process_keyqueue(keys, True)
processed += run
except escape.MoreInputRequired:
key = self._getch(self.complete_tenths)
while key >= 0:
raw.append(key)
if key == KEY_RESIZE:
resize = True
elif key == KEY_MOUSE:
keys += self._encode_mouse_event()
else:
keys.append(key)
key = self._getch_nodelay()
while keys:
run, keys = escape.process_keyqueue(keys, False)
processed += run
if resize:
processed.append("window resize")
return processed, raw
def _encode_mouse_event(self) -> list[int]:
# convert to escape sequence
last_state = next_state = self.last_bstate
(_id, x, y, _z, bstate) = curses.getmouse()
mod = 0
if bstate & curses.BUTTON_SHIFT:
mod |= 4
if bstate & curses.BUTTON_ALT:
mod |= 8
if bstate & curses.BUTTON_CTRL:
mod |= 16
result = []
def append_button(b: int) -> None:
b |= mod
result.extend([27, ord("["), ord("M"), b + 32, x + 33, y + 33])
if bstate & curses.BUTTON1_PRESSED and last_state & 1 == 0:
append_button(0)
next_state |= 1
if bstate & curses.BUTTON2_PRESSED and last_state & 2 == 0:
append_button(1)
next_state |= 2
if bstate & curses.BUTTON3_PRESSED and last_state & 4 == 0:
append_button(2)
next_state |= 4
if bstate & curses.BUTTON4_PRESSED and last_state & 8 == 0:
append_button(64)
next_state |= 8
if bstate & curses.BUTTON1_RELEASED and last_state & 1:
append_button(0 + escape.MOUSE_RELEASE_FLAG)
next_state &= ~1
if bstate & curses.BUTTON2_RELEASED and last_state & 2:
append_button(1 + escape.MOUSE_RELEASE_FLAG)
next_state &= ~2
if bstate & curses.BUTTON3_RELEASED and last_state & 4:
append_button(2 + escape.MOUSE_RELEASE_FLAG)
next_state &= ~4
if bstate & curses.BUTTON4_RELEASED and last_state & 8:
append_button(64 + escape.MOUSE_RELEASE_FLAG)
next_state &= ~8
if bstate & curses.BUTTON1_DOUBLE_CLICKED:
append_button(0 + escape.MOUSE_MULTIPLE_CLICK_FLAG)
if bstate & curses.BUTTON2_DOUBLE_CLICKED:
append_button(1 + escape.MOUSE_MULTIPLE_CLICK_FLAG)
if bstate & curses.BUTTON3_DOUBLE_CLICKED:
append_button(2 + escape.MOUSE_MULTIPLE_CLICK_FLAG)
if bstate & curses.BUTTON4_DOUBLE_CLICKED:
append_button(64 + escape.MOUSE_MULTIPLE_CLICK_FLAG)
if bstate & curses.BUTTON1_TRIPLE_CLICKED:
append_button(0 + escape.MOUSE_MULTIPLE_CLICK_FLAG * 2)
if bstate & curses.BUTTON2_TRIPLE_CLICKED:
append_button(1 + escape.MOUSE_MULTIPLE_CLICK_FLAG * 2)
if bstate & curses.BUTTON3_TRIPLE_CLICKED:
append_button(2 + escape.MOUSE_MULTIPLE_CLICK_FLAG * 2)
if bstate & curses.BUTTON4_TRIPLE_CLICKED:
append_button(64 + escape.MOUSE_MULTIPLE_CLICK_FLAG * 2)
self.last_bstate = next_state
return result
def _dbg_instr(self): # messy input string (intended for debugging)
curses.echo()
self.s.nodelay(0)
curses.halfdelay(100)
string = self.s.getstr()
curses.noecho()
return string
def _dbg_out(self, string) -> None: # messy output function (intended for debugging)
self.s.clrtoeol()
self.s.addstr(string)
self.s.refresh()
self._curs_set(1)
def _dbg_query(self, question): # messy query (intended for debugging)
self._dbg_out(question)
return self._dbg_instr()
def _dbg_refresh(self) -> None:
self.s.refresh()
def get_cols_rows(self) -> tuple[int, int]:
"""Return the terminal dimensions (num columns, num rows)."""
rows, cols = self.s.getmaxyx()
return cols, rows
def _setattr(self, a):
if a is None:
self.s.attrset(0)
return
if not isinstance(a, AttrSpec):
p = self._palette.get(a, (AttrSpec("default", "default"),))
a = p[0]
if self.has_color:
if a.foreground_basic:
if a.foreground_number >= 8:
fg = a.foreground_number - 8
else:
fg = a.foreground_number
else:
fg = 7
if a.background_basic:
bg = a.background_number
else:
bg = 0
attr = curses.color_pair(bg * 8 + 7 - fg)
else:
attr = 0
if a.bold:
attr |= curses.A_BOLD
if a.standout:
attr |= curses.A_STANDOUT
if a.underline:
attr |= curses.A_UNDERLINE
if a.blink:
attr |= curses.A_BLINK
self.s.attrset(attr)
def draw_screen(self, size: tuple[int, int], canvas: Canvas) -> None:
"""Paint screen with rendered canvas."""
logger = self.logger.getChild("draw_screen")
if not self._started:
raise RuntimeError
_cols, rows = size
if canvas.rows() != rows:
raise ValueError("canvas size and passed size don't match")
logger.debug(f"Drawing screen with size {size!r}")
y = -1
for row in canvas.content():
y += 1
try:
self.s.move(y, 0)
except curses.error:
# terminal shrunk?
# move failed so stop rendering.
return
first = True
lasta = None
for nr, (a, cs, seg) in enumerate(row):
if cs != "U":
seg = seg.translate(UNPRINTABLE_TRANS_TABLE) # noqa: PLW2901
if not isinstance(seg, bytes):
raise TypeError(seg)
if first or lasta != a:
self._setattr(a)
lasta = a
try:
if cs in {"0", "U"}:
for segment in seg:
self.s.addch(0x400000 + segment)
else:
if cs is not None:
raise ValueError(f"cs not in ('0', 'U' ,'None'): {cs!r}")
if not isinstance(seg, bytes):
raise TypeError(seg)
self.s.addstr(seg.decode(util.get_encoding()))
except curses.error:
# it's OK to get out of the
# screen on the lower right
if y != rows - 1 or nr != len(row) - 1:
# perhaps screen size changed
# quietly abort.
return
if canvas.cursor is not None:
x, y = canvas.cursor
self._curs_set(1)
with suppress(curses.error):
self.s.move(y, x)
else:
self._curs_set(0)
self.s.move(0, 0)
self.s.refresh()
self.keep_cache_alive_link = canvas
def clear(self) -> None:
"""
Force the screen to be completely repainted on the next call to draw_screen().
"""
self.s.clear()
class _test:
def __init__(self):
self.ui = Screen()
self.l = sorted(_curses_colours)
for c in self.l:
self.ui.register_palette(
[
(f"{c} on black", c, "black", "underline"),
(f"{c} on dark blue", c, "dark blue", "bold"),
(f"{c} on light gray", c, "light gray", "standout"),
]
)
with self.ui.start():
self.run()
def run(self) -> None:
class FakeRender:
pass
r = FakeRender()
text = [f" has_color = {self.ui.has_color!r}", ""]
attr = [[], []]
r.coords = {}
r.cursor = None
for c in self.l:
t = ""
a = []
for p in f"{c} on black", f"{c} on dark blue", f"{c} on light gray":
a.append((p, 27))
t += (p + 27 * " ")[:27]
text.append(t)
attr.append(a)
text += ["", "return values from get_input(): (q exits)", ""]
attr += [[], [], []]
cols, rows = self.ui.get_cols_rows()
keys = None
while keys != ["q"]:
r.text = ([t.ljust(cols) for t in text] + [""] * rows)[:rows]
r.attr = (attr + [[] for _ in range(rows)])[:rows]
self.ui.draw_screen((cols, rows), r)
keys, raw = self.ui.get_input(raw_keys=True)
if "window resize" in keys:
cols, rows = self.ui.get_cols_rows()
if not keys:
continue
t = ""
a = []
for k in keys:
if isinstance(k, str):
k = k.encode(util.get_encoding()) # noqa: PLW2901
t += f"'{k}' "
a += [(None, 1), ("yellow on dark blue", len(k)), (None, 2)]
text.append(f"{t}: {raw!r}")
attr.append(a)
text = text[-rows:]
attr = attr[-rows:]
if __name__ == "__main__":
_test()
@@ -0,0 +1,637 @@
# Urwid escape sequences common to curses_display and raw_display
# Copyright (C) 2004-2011 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
"""
Terminal Escape Sequences for input and display
"""
from __future__ import annotations
import re
import sys
import typing
from collections.abc import MutableMapping, Sequence
from urwid import str_util
if typing.TYPE_CHECKING:
from collections.abc import Iterable
# NOTE: because of circular imports (urwid.util -> urwid.escape -> urwid.util)
# from urwid.util import is_mouse_event -- will not work here
import urwid.util # isort: skip # pylint: disable=wrong-import-position
IS_WINDOWS = sys.platform == "win32"
within_double_byte = str_util.within_double_byte
SO = "\x0e"
SI = "\x0f"
IBMPC_ON = "\x1b[11m"
IBMPC_OFF = "\x1b[10m"
DEC_TAG = "0"
DEC_SPECIAL_CHARS = "▮◆▒␉␌␍␊°±␤␋┘┐┌└┼⎺⎻─⎼⎽├┤┴┬│≤≥π≠£·"
ALT_DEC_SPECIAL_CHARS = "_`abcdefghijklmnopqrstuvwxyz{|}~"
DEC_SPECIAL_CHARMAP = {}
if len(DEC_SPECIAL_CHARS) != len(ALT_DEC_SPECIAL_CHARS):
raise RuntimeError(repr((DEC_SPECIAL_CHARS, ALT_DEC_SPECIAL_CHARS)))
for c, alt in zip(DEC_SPECIAL_CHARS, ALT_DEC_SPECIAL_CHARS):
DEC_SPECIAL_CHARMAP[ord(c)] = SO + alt + SI
SAFE_ASCII_DEC_SPECIAL_RE = re.compile(f"^[ -~{DEC_SPECIAL_CHARS}]*$")
DEC_SPECIAL_RE = re.compile(f"[{DEC_SPECIAL_CHARS}]")
###################
# Input sequences
###################
class MoreInputRequired(Exception):
pass
def escape_modifier(digit: str) -> str:
mode = ord(digit) - ord("1")
return "shift " * (mode & 1) + "meta " * ((mode & 2) // 2) + "ctrl " * ((mode & 4) // 4)
input_sequences = [
("[A", "up"),
("[B", "down"),
("[C", "right"),
("[D", "left"),
("[E", "5"),
("[F", "end"),
("[G", "5"),
("[H", "home"),
("[I", "focus in"),
("[O", "focus out"),
("[1~", "home"),
("[2~", "insert"),
("[3~", "delete"),
("[4~", "end"),
("[5~", "page up"),
("[6~", "page down"),
("[7~", "home"),
("[8~", "end"),
("[[A", "f1"),
("[[B", "f2"),
("[[C", "f3"),
("[[D", "f4"),
("[[E", "f5"),
("[11~", "f1"),
("[12~", "f2"),
("[13~", "f3"),
("[14~", "f4"),
("[15~", "f5"),
("[17~", "f6"),
("[18~", "f7"),
("[19~", "f8"),
("[20~", "f9"),
("[21~", "f10"),
("[23~", "f11"),
("[24~", "f12"),
("[25~", "f13"),
("[26~", "f14"),
("[28~", "f15"),
("[29~", "f16"),
("[31~", "f17"),
("[32~", "f18"),
("[33~", "f19"),
("[34~", "f20"),
("OA", "up"),
("OB", "down"),
("OC", "right"),
("OD", "left"),
("OH", "home"),
("OF", "end"),
("OP", "f1"),
("OQ", "f2"),
("OR", "f3"),
("OS", "f4"),
("Oo", "/"),
("Oj", "*"),
("Om", "-"),
("Ok", "+"),
("[Z", "shift tab"),
("On", "."),
("[200~", "begin paste"),
("[201~", "end paste"),
*(
(prefix + letter, modifier + key)
for prefix, modifier in zip("O[", ("meta ", "shift "))
for letter, key in zip("abcd", ("up", "down", "right", "left"))
),
*(
(f"[{digit}{symbol}", modifier + key)
for modifier, symbol in zip(("shift ", "meta "), "$^")
for digit, key in zip("235678", ("insert", "delete", "page up", "page down", "home", "end"))
),
*((f"O{ord('p') + n:c}", str(n)) for n in range(10)),
*(
# modified cursor keys + home, end, 5 -- [#X and [1;#X forms
(prefix + digit + letter, escape_modifier(digit) + key)
for prefix in ("[", "[1;")
for digit in "12345678"
for letter, key in zip("ABCDEFGH", ("up", "down", "right", "left", "5", "end", "5", "home"))
),
*(
# modified F1-F4 keys - O#X form and [1;#X form
(prefix + digit + letter, escape_modifier(digit) + f"f{number}")
for prefix in ("O", "[1;")
for digit in "12345678"
for number, letter in enumerate("PQRS", start=1)
),
*(
# modified F1-F13 keys -- [XX;#~ form
(f"[{num!s};{digit}~", escape_modifier(digit) + key)
for digit in "12345678"
for num, key in zip(
(3, 5, 6, 11, 12, 13, 14, 15, 17, 18, 19, 20, 21, 23, 24, 25, 26, 28, 29, 31, 32, 33, 34),
(
"delete",
"page up",
"page down",
*(f"f{idx}" for idx in range(1, 21)),
),
)
),
# mouse reporting (special handling done in KeyqueueTrie)
("[M", "mouse"),
# mouse reporting for SGR 1006
("[<", "sgrmouse"),
# report status response
("[0n", "status ok"),
]
class KeyqueueTrie:
__slots__ = ("data",)
def __init__(self, sequences: Iterable[tuple[str, str]]) -> None:
self.data: dict[int, str | dict[int, str | dict[int, str]]] = {}
for s, result in sequences:
if isinstance(result, dict):
raise TypeError(result)
self.add(self.data, s, result)
def add(
self,
root: MutableMapping[int, str | MutableMapping[int, str | MutableMapping[int, str]]],
s: str,
result: str,
) -> None:
if not isinstance(root, MutableMapping) or not s:
raise RuntimeError("trie conflict detected")
if ord(s[0]) in root:
self.add(root[ord(s[0])], s[1:], result)
return
if len(s) > 1:
d = {}
root[ord(s[0])] = d
self.add(d, s[1:], result)
return
root[ord(s)] = result
def get(self, keys, more_available: bool):
if result := self.get_recurse(self.data, keys, more_available):
return result
return self.read_cursor_position(keys, more_available)
def get_recurse(
self,
root: (
MutableMapping[int, str | MutableMapping[int, str | MutableMapping[int, str]]]
| typing.Literal["mouse", "sgrmouse"]
),
keys: Sequence[int],
more_available: bool,
):
if not isinstance(root, MutableMapping):
if root == "mouse":
return self.read_mouse_info(keys, more_available)
if root == "sgrmouse":
return self.read_sgrmouse_info(keys, more_available)
return (root, keys)
if not keys:
# get more keys
if more_available:
raise MoreInputRequired()
return None
if keys[0] not in root:
return None
return self.get_recurse(root[keys[0]], keys[1:], more_available)
def read_mouse_info(
self,
keys: Sequence[int],
more_available: bool,
) -> tuple[tuple[str, int, int, int], Sequence[int]] | None:
if len(keys) < 3:
if more_available:
raise MoreInputRequired()
return None
b = keys[0] - 32
x, y = (keys[1] - 33) % 256, (keys[2] - 33) % 256 # supports 0-255
prefixes = []
if b & 4:
prefixes.append("shift ")
if b & 8:
prefixes.append("meta ")
if b & 16:
prefixes.append("ctrl ")
if (b & MOUSE_MULTIPLE_CLICK_MASK) >> 9 == 1:
prefixes.append("double ")
if (b & MOUSE_MULTIPLE_CLICK_MASK) >> 9 == 2:
prefixes.append("triple ")
prefix = "".join(prefixes)
# 0->1, 1->2, 2->3, 64->4, 65->5
button = ((b & 64) // 64 * 3) + (b & 3) + 1
if b & 3 == 3:
action = "release"
button = 0
elif b & MOUSE_RELEASE_FLAG:
action = "release"
elif b & MOUSE_DRAG_FLAG:
action = "drag"
elif b & MOUSE_MULTIPLE_CLICK_MASK:
action = "click"
else:
action = "press"
return ((f"{prefix}mouse {action}", button, x, y), keys[3:])
def read_sgrmouse_info(
self,
keys: Sequence[int],
more_available: bool,
) -> tuple[tuple[str, int, int, int], Sequence[int]] | None:
# Helpful links:
# https://stackoverflow.com/questions/5966903/how-to-get-mousemove-and-mouseclick-in-bash
# http://invisible-island.net/xterm/ctlseqs/ctlseqs.pdf
if not keys:
if more_available:
raise MoreInputRequired()
return None
value = ""
pos_m = 0
found_m = False
for k in keys:
value += chr(k)
if k in {ord("M"), ord("m")}:
found_m = True
break
pos_m += 1
if not found_m:
if more_available:
raise MoreInputRequired()
return None
(b, x, y) = (int(val) for val in value[:-1].split(";"))
action = value[-1]
# Double and triple clicks are not supported.
# They can be implemented by using a timer.
# This timer can check if the last registered click is below a certain threshold.
# This threshold is normally set in the operating system itself,
# so setting one here will cause an inconsistent behaviour.
prefixes = []
if b & 4:
prefixes.append("shift ")
if b & 8:
prefixes.append("meta ")
if b & 16:
prefixes.append("ctrl ")
prefix = "".join(prefixes)
wheel_used: typing.Literal[0, 1] = (b & 64) >> 6
button = (wheel_used * 3) + (b & 3) + 1
x -= 1
y -= 1
if action == "M":
if b & MOUSE_DRAG_FLAG:
action = "drag"
else:
action = "press"
elif action == "m":
action = "release"
else:
raise ValueError(f"Unknown mouse action: {action!r}")
return ((f"{prefix}mouse {action}", button, x, y), keys[pos_m + 1 :])
def read_cursor_position(
self,
keys: Sequence[int],
more_available: bool,
) -> tuple[tuple[str, int, int], Sequence[int]] | None:
"""
Interpret cursor position information being sent by the
user's terminal. Returned as ('cursor position', x, y)
where (x, y) == (0, 0) is the top left of the screen.
"""
if not keys:
if more_available:
raise MoreInputRequired()
return None
if keys[0] != ord("["):
return None
# read y value
y = 0
i = 1
for k in keys[i:]:
i += 1
if k == ord(";"):
if not y:
return None
break
if k < ord("0") or k > ord("9"):
return None
if not y and k == ord("0"):
return None
y = y * 10 + k - ord("0")
if not keys[i:]:
if more_available:
raise MoreInputRequired()
return None
# read x value
x = 0
for k in keys[i:]:
i += 1
if k == ord("R"):
if not x:
return None
return (("cursor position", x - 1, y - 1), keys[i:])
if k < ord("0") or k > ord("9"):
return None
if not x and k == ord("0"):
return None
x = x * 10 + k - ord("0")
if not keys[i:] and more_available:
raise MoreInputRequired()
return None
# This is added to button value to signal mouse release by curses_display
# and raw_display when we know which button was released. NON-STANDARD
MOUSE_RELEASE_FLAG = 2048
# This 2-bit mask is used to check if the mouse release from curses or gpm
# is a double or triple release. 00 means single click, 01 double,
# 10 triple. NON-STANDARD
MOUSE_MULTIPLE_CLICK_MASK = 1536
# This is added to button value at mouse release to differentiate between
# single, double and triple press. Double release adds this times one,
# triple release adds this times two. NON-STANDARD
MOUSE_MULTIPLE_CLICK_FLAG = 512
# xterm adds this to the button value to signal a mouse drag event
MOUSE_DRAG_FLAG = 32
#################################################
# Build the input trie from input_sequences list
input_trie = KeyqueueTrie(input_sequences)
#################################################
_keyconv = {
-1: None,
8: "backspace",
9: "tab",
10: "enter",
13: "enter",
127: "backspace",
# curses-only keycodes follow.. (XXX: are these used anymore?)
258: "down",
259: "up",
260: "left",
261: "right",
262: "home",
263: "backspace",
265: "f1",
266: "f2",
267: "f3",
268: "f4",
269: "f5",
270: "f6",
271: "f7",
272: "f8",
273: "f9",
274: "f10",
275: "f11",
276: "f12",
277: "shift f1",
278: "shift f2",
279: "shift f3",
280: "shift f4",
281: "shift f5",
282: "shift f6",
283: "shift f7",
284: "shift f8",
285: "shift f9",
286: "shift f10",
287: "shift f11",
288: "shift f12",
330: "delete",
331: "insert",
338: "page down",
339: "page up",
343: "enter", # on numpad
350: "5", # on numpad
360: "end",
}
if IS_WINDOWS:
_keyconv[351] = "shift tab"
_keyconv[358] = "end"
def process_keyqueue(codes: Sequence[int], more_available: bool) -> tuple[list[str], Sequence[int]]:
"""
codes -- list of key codes
more_available -- if True then raise MoreInputRequired when in the
middle of a character sequence (escape/utf8/wide) and caller
will attempt to send more key codes on the next call.
returns (list of input, list of remaining key codes).
"""
code = codes[0]
if 32 <= code <= 126:
key = chr(code)
return [key], codes[1:]
if code in _keyconv:
return [_keyconv[code]], codes[1:]
if 0 < code < 27:
return [f"ctrl {ord('a') + code - 1:c}"], codes[1:]
if 27 < code < 32:
return [f"ctrl {ord('A') + code - 1:c}"], codes[1:]
em = str_util.get_byte_encoding()
if (
em == "wide"
and code < 256
and within_double_byte(
code.to_bytes(1, "little"),
0,
0,
)
):
if not codes[1:] and more_available:
raise MoreInputRequired()
if codes[1:] and codes[1] < 256:
db = chr(code) + chr(codes[1])
if within_double_byte(db, 0, 1):
return [db], codes[2:]
if em == "utf8" and 127 < code < 256:
if code & 0xE0 == 0xC0: # 2-byte form
need_more = 1
elif code & 0xF0 == 0xE0: # 3-byte form
need_more = 2
elif code & 0xF8 == 0xF0: # 4-byte form
need_more = 3
else:
return [f"<{code:d}>"], codes[1:]
for i in range(1, need_more + 1):
if len(codes) <= i:
if more_available:
raise MoreInputRequired()
return [f"<{code:d}>"], codes[1:]
k = codes[i]
if k > 256 or k & 0xC0 != 0x80:
return [f"<{code:d}>"], codes[1:]
s = bytes(codes[: need_more + 1])
try:
return [s.decode("utf-8")], codes[need_more + 1 :]
except UnicodeDecodeError:
return [f"<{code:d}>"], codes[1:]
if 127 < code < 256:
key = chr(code)
return [key], codes[1:]
if code != 27:
return [f"<{code:d}>"], codes[1:]
if (result := input_trie.get(codes[1:], more_available)) is not None:
result, remaining_codes = result
return [result], remaining_codes
if codes[1:]:
# Meta keys -- ESC+Key form
run, remaining_codes = process_keyqueue(codes[1:], more_available)
if urwid.util.is_mouse_event(run[0]):
return ["esc", *run], remaining_codes
if run[0] == "esc" or run[0].find("meta ") >= 0:
return ["esc", *run], remaining_codes
return [f"meta {run[0]}", *run[1:]], remaining_codes
return ["esc"], codes[1:]
####################
# Output sequences
####################
ESC = "\x1b"
CURSOR_HOME = f"{ESC}[H"
CURSOR_HOME_COL = "\r"
APP_KEYPAD_MODE = f"{ESC}="
NUM_KEYPAD_MODE = f"{ESC}>"
SWITCH_TO_ALTERNATE_BUFFER = f"{ESC}[?1049h"
RESTORE_NORMAL_BUFFER = f"{ESC}[?1049l"
ENABLE_BRACKETED_PASTE_MODE = f"{ESC}[?2004h"
DISABLE_BRACKETED_PASTE_MODE = f"{ESC}[?2004l"
ENABLE_FOCUS_REPORTING = f"{ESC}[?1004h"
DISABLE_FOCUS_REPORTING = f"{ESC}[?1004l"
# RESET_SCROLL_REGION = ESC+"[;r"
# RESET = ESC+"c"
REPORT_STATUS = f"{ESC}[5n"
REPORT_CURSOR_POSITION = f"{ESC}[6n"
INSERT_ON = f"{ESC}[4h"
INSERT_OFF = f"{ESC}[4l"
def set_cursor_position(x: int, y: int) -> str:
if not isinstance(x, int):
raise TypeError(x)
if not isinstance(y, int):
raise TypeError(y)
return ESC + f"[{y + 1:d};{x + 1:d}H"
def move_cursor_right(x: int) -> str:
if x < 1:
return ""
return ESC + f"[{x:d}C"
def move_cursor_up(x: int) -> str:
if x < 1:
return ""
return ESC + f"[{x:d}A"
def move_cursor_down(x: int) -> str:
if x < 1:
return ""
return ESC + f"[{x:d}B"
HIDE_CURSOR = f"{ESC}[?25l"
SHOW_CURSOR = f"{ESC}[?25h"
MOUSE_TRACKING_ON = f"{ESC}[?1000h{ESC}[?1002h{ESC}[?1006h"
MOUSE_TRACKING_OFF = f"{ESC}[?1006l{ESC}[?1002l{ESC}[?1000l"
DESIGNATE_G1_SPECIAL = f"{ESC})0"
ERASE_IN_LINE_RIGHT = f"{ESC}[K"
@@ -0,0 +1,244 @@
# Urwid html fragment output wrapper for "screen shots"
# Copyright (C) 2004-2007 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
"""
HTML PRE-based UI implementation
"""
from __future__ import annotations
import html
import typing
from urwid import str_util
from urwid.event_loop import ExitMainLoop
from urwid.util import get_encoding
from .common import AttrSpec, BaseScreen
if typing.TYPE_CHECKING:
from typing_extensions import Literal
from urwid import Canvas
# replace control characters with ?'s
_trans_table = "?" * 32 + "".join(chr(x) for x in range(32, 256))
_default_foreground = "black"
_default_background = "light gray"
class HtmlGeneratorSimulationError(Exception):
pass
class HtmlGenerator(BaseScreen):
# class variables
fragments: typing.ClassVar[list[str]] = []
sizes: typing.ClassVar[list[tuple[int, int]]] = []
keys: typing.ClassVar[list[list[str]]] = []
started = True
def __init__(self) -> None:
super().__init__()
self.colors = 16
self.bright_is_bold = False # ignored
self.has_underline = True # ignored
self.register_palette_entry(None, _default_foreground, _default_background)
def set_terminal_properties(
self,
colors: int | None = None,
bright_is_bold: bool | None = None,
has_underline: bool | None = None,
) -> None:
if colors is None:
colors = self.colors
if bright_is_bold is None:
bright_is_bold = self.bright_is_bold
if has_underline is None:
has_underline = self.has_underline
self.colors = colors
self.bright_is_bold = bright_is_bold
self.has_underline = has_underline
def set_input_timeouts(self, *args: typing.Any) -> None:
pass
def reset_default_terminal_palette(self, *args: typing.Any) -> None:
pass
def draw_screen(self, size: tuple[int, int], canvas: Canvas) -> None:
"""Create an html fragment from the render object.
Append it to HtmlGenerator.fragments list.
"""
# collect output in l
lines = []
_cols, rows = size
if canvas.rows() != rows:
raise ValueError(rows)
if canvas.cursor is not None:
cx, cy = canvas.cursor
else:
cx = cy = None
for y, row in enumerate(canvas.content()):
col = 0
for a, _cs, run in row:
t_run = run.decode(get_encoding()).translate(_trans_table)
if isinstance(a, AttrSpec):
aspec = a
else:
aspec = self._palette[a][{1: 1, 16: 0, 88: 2, 256: 3}[self.colors]]
if y == cy and col <= cx:
run_width = str_util.calc_width(t_run, 0, len(t_run))
if col + run_width > cx:
lines.append(html_span(t_run, aspec, cx - col))
else:
lines.append(html_span(t_run, aspec))
col += run_width
else:
lines.append(html_span(t_run, aspec))
lines.append("\n")
# add the fragment to the list
self.fragments.append(f"<pre>{''.join(lines)}</pre>")
def get_cols_rows(self):
"""Return the next screen size in HtmlGenerator.sizes."""
if not self.sizes:
raise HtmlGeneratorSimulationError("Ran out of screen sizes to return!")
return self.sizes.pop(0)
@typing.overload
def get_input(self, raw_keys: Literal[False]) -> list[str]: ...
@typing.overload
def get_input(self, raw_keys: Literal[True]) -> tuple[list[str], list[int]]: ...
def get_input(self, raw_keys: bool = False) -> list[str] | tuple[list[str], list[int]]:
"""Return the next list of keypresses in HtmlGenerator.keys."""
if not self.keys:
raise ExitMainLoop()
if raw_keys:
return (self.keys.pop(0), [])
return self.keys.pop(0)
_default_aspec = AttrSpec(_default_foreground, _default_background)
(_d_fg_r, _d_fg_g, _d_fg_b, _d_bg_r, _d_bg_g, _d_bg_b) = _default_aspec.get_rgb_values()
def html_span(s: str, aspec: AttrSpec, cursor: int = -1) -> str:
fg_r, fg_g, fg_b, bg_r, bg_g, bg_b = aspec.get_rgb_values()
# use real colours instead of default fg/bg
if fg_r is None:
fg_r, fg_g, fg_b = _d_fg_r, _d_fg_g, _d_fg_b
if bg_r is None:
bg_r, bg_g, bg_b = _d_bg_r, _d_bg_g, _d_bg_b
html_fg = f"#{fg_r:02x}{fg_g:02x}{fg_b:02x}"
html_bg = f"#{bg_r:02x}{bg_g:02x}{bg_b:02x}"
if aspec.standout:
html_fg, html_bg = html_bg, html_fg
extra = ";text-decoration:underline" * aspec.underline + ";font-weight:bold" * aspec.bold
def _span(fg: str, bg: str, string: str) -> str:
if not s:
return ""
return f'<span style="color:{fg};background:{bg}{extra}">{html.escape(string)}</span>'
if cursor >= 0:
c_off, _ign = str_util.calc_text_pos(s, 0, len(s), cursor)
c2_off = str_util.move_next_char(s, c_off, len(s))
return (
_span(html_fg, html_bg, s[:c_off])
+ _span(html_bg, html_fg, s[c_off:c2_off])
+ _span(html_fg, html_bg, s[c2_off:])
)
return _span(html_fg, html_bg, s)
def screenshot_init(sizes: list[tuple[int, int]], keys: list[list[str]]) -> None:
"""
Replace curses_display.Screen and raw_display.Screen class with
HtmlGenerator.
Call this function before executing an application that uses
curses_display.Screen to have that code use HtmlGenerator instead.
sizes -- list of ( columns, rows ) tuples to be returned by each call
to HtmlGenerator.get_cols_rows()
keys -- list of lists of keys to be returned by each call to
HtmlGenerator.get_input()
Lists of keys may include "window resize" to force the application to
call get_cols_rows and read a new screen size.
For example, the following call will prepare an application to:
1. start in 80x25 with its first call to get_cols_rows()
2. take a screenshot when it calls draw_screen(..)
3. simulate 5 "down" keys from get_input()
4. take a screenshot when it calls draw_screen(..)
5. simulate keys "a", "b", "c" and a "window resize"
6. resize to 20x10 on its second call to get_cols_rows()
7. take a screenshot when it calls draw_screen(..)
8. simulate a "Q" keypress to quit the application
screenshot_init( [ (80,25), (20,10) ],
[ ["down"]*5, ["a","b","c","window resize"], ["Q"] ] )
"""
for row, col in sizes:
if not isinstance(row, int):
raise TypeError(f"sizes must be list[tuple[int, int]], with values >0 : {row!r}")
if row <= 0:
raise ValueError(f"sizes must be list[tuple[int, int]], with values >0 : {row!r}")
if not isinstance(col, int):
raise TypeError(f"sizes must be list[tuple[int, int]], with values >0 : {col!r}")
if col <= 0:
raise ValueError(f"sizes must be list[tuple[int, int]], with values >0 : {col!r}")
for line in keys:
if not isinstance(line, list):
raise TypeError(f"keys must be list[list[str]]: {line!r}")
for k in line:
if not isinstance(k, str):
raise TypeError(f"keys must be list[list[str]]: {k!r}")
from . import curses, raw
curses.Screen = HtmlGenerator
raw.Screen = HtmlGenerator
HtmlGenerator.sizes = sizes
HtmlGenerator.keys = keys
def screenshot_collect() -> list[str]:
"""Return screenshots as a list of HTML fragments."""
fragments, HtmlGenerator.fragments = HtmlGenerator.fragments, []
return fragments
@@ -0,0 +1,524 @@
# Urwid LCD display module
# Copyright (C) 2010 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
import abc
import time
import typing
from .common import BaseScreen
if typing.TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from typing_extensions import Literal
from urwid import Canvas
class LCDScreen(BaseScreen, abc.ABC):
"""Base class for LCD-based screens."""
DISPLAY_SIZE: tuple[int, int]
def set_terminal_properties(
self,
colors: Literal[1, 16, 88, 256, 16777216] | None = None,
bright_is_bold: bool | None = None,
has_underline: bool | None = None,
) -> None:
pass
def set_input_timeouts(self, *args):
pass
def reset_default_terminal_palette(self, *args):
pass
def get_cols_rows(self):
return self.DISPLAY_SIZE
class CFLCDScreen(LCDScreen, abc.ABC):
"""
Common methods for Crystal Fonts LCD displays
"""
KEYS: typing.ClassVar[list[str | None]] = [
None, # no key with code 0
"up_press",
"down_press",
"left_press",
"right_press",
"enter_press",
"exit_press",
"up_release",
"down_release",
"left_release",
"right_release",
"enter_release",
"exit_release",
"ul_press",
"ur_press",
"ll_press",
"lr_press",
"ul_release",
"ur_release",
"ll_release",
"lr_release",
]
CMD_PING = 0
CMD_VERSION = 1
CMD_CLEAR = 6
CMD_CGRAM = 9
CMD_CURSOR_POSITION = 11 # data = [col, row]
CMD_CURSOR_STYLE = 12 # data = [style (0-4)]
CMD_LCD_CONTRAST = 13 # data = [contrast (0-255)]
CMD_BACKLIGHT = 14 # data = [power (0-100)]
CMD_LCD_DATA = 31 # data = [col, row] + text
CMD_GPO = 34 # data = [pin(0-12), value(0-100)]
# sent from device
CMD_KEY_ACTIVITY = 0x80
CMD_ACK = 0x40 # in high two bits ie. & 0xc0
CURSOR_NONE = 0
CURSOR_BLINKING_BLOCK = 1
CURSOR_UNDERSCORE = 2
CURSOR_BLINKING_BLOCK_UNDERSCORE = 3
CURSOR_INVERTING_BLINKING_BLOCK = 4
MAX_PACKET_DATA_LENGTH = 22
colors = 1
has_underline = False
def __init__(self, device_path: str, baud: int) -> None:
"""
device_path -- eg. '/dev/ttyUSB0'
baud -- baud rate
"""
super().__init__()
self.device_path = device_path
from serial import Serial
self._device = Serial(device_path, baud, timeout=0)
self._unprocessed = bytearray()
@classmethod
def get_crc(cls, buf: Iterable[int]) -> bytes:
# This seed makes the output of this shift-based algorithm match
# the table-based algorithm. The center 16 bits of the 32-bit
# "newCRC" are used for the CRC. The MSB of the lower byte is used
# to see what bit was shifted out of the center 16 bit CRC
# accumulator ("carry flag analog");
new_crc = 0x00F32100
for byte in buf:
# Push this byte's bits through a software
# implementation of a hardware shift & xor.
for bit_count in range(8):
# Shift the CRC accumulator
new_crc >>= 1
# The new MSB of the CRC accumulator comes
# from the LSB of the current data byte.
if byte & (0x01 << bit_count):
new_crc |= 0x00800000
# If the low bit of the current CRC accumulator was set
# before the shift, then we need to XOR the accumulator
# with the polynomial (center 16 bits of 0x00840800)
if new_crc & 0x00000080:
new_crc ^= 0x00840800
# All the data has been done. Do 16 more bits of 0 data.
for _bit_count in range(16):
# Shift the CRC accumulator
new_crc >>= 1
# If the low bit of the current CRC accumulator was set
# before the shift we need to XOR the accumulator with
# 0x00840800.
if new_crc & 0x00000080:
new_crc ^= 0x00840800
# Return the center 16 bits, making this CRC match the one's
# complement that is sent in the packet.
return (((~new_crc) >> 8) & 0xFFFF).to_bytes(2, "little")
def _send_packet(self, command: int, data: bytes) -> None:
"""
low-level packet sending.
Following the protocol requires waiting for ack packet between
sending each packet to the device.
"""
buf = bytearray([command, len(data)])
buf.extend(data)
buf.extend(self.get_crc(buf))
self._device.write(buf)
def _read_packet(self) -> tuple[int, bytearray] | None:
"""
low-level packet reading.
returns (command/report code, data) or None
This method stored data read and tries to resync when bad data
is received.
"""
# pull in any new data available
self._unprocessed += self._device.read()
while True:
try:
command, data, unprocessed = self._parse_data(self._unprocessed)
self._unprocessed = unprocessed
except self.MoreDataRequired: # noqa: PERF203
return None
except self.InvalidPacket:
# throw out a byte and try to parse again
self._unprocessed = self._unprocessed[1:]
else:
return command, data
class InvalidPacket(Exception):
pass
class MoreDataRequired(Exception):
pass
@classmethod
def _parse_data(cls, data: bytearray) -> tuple[int, bytearray, bytearray]:
"""
Try to read a packet from the start of data, returning
(command/report code, packet_data, remaining_data)
or raising InvalidPacket or MoreDataRequired
"""
if len(data) < 2:
raise cls.MoreDataRequired
command: int = data[0]
packet_len: int = data[1]
if packet_len > cls.MAX_PACKET_DATA_LENGTH:
raise cls.InvalidPacket("length value too large")
if len(data) < packet_len + 4:
raise cls.MoreDataRequired
data_end = 2 + packet_len
if (crc := cls.get_crc(data[:data_end])) != (packet_crc := data[data_end : data_end + 2]):
raise cls.InvalidPacket(f"CRC doesn't match ({crc=}, {packet_crc=})")
return command, data[2:data_end], data[data_end + 2 :]
class KeyRepeatSimulator:
"""
Provide simulated repeat key events when given press and
release events.
If two or more keys are pressed, disable repeating until all
keys are released.
"""
def __init__(self, repeat_delay: float, repeat_next: float) -> None:
"""
repeat_delay -- seconds to wait before starting to repeat keys
repeat_next -- time between each repeated key
"""
self.repeat_delay = repeat_delay
self.repeat_next = repeat_next
self.pressed: dict[str, float] = {}
self.multiple_pressed = False
def press(self, key: str) -> None:
if self.pressed:
self.multiple_pressed = True
self.pressed[key] = time.time()
def release(self, key: str) -> None:
if key not in self.pressed:
return # ignore extra release events
del self.pressed[key]
if not self.pressed:
self.multiple_pressed = False
def next_event(self) -> tuple[float, str] | None:
"""
Return (remaining, key) where remaining is the number of seconds
(float) until the key repeat event should be sent, or None if no
events are pending.
"""
if len(self.pressed) != 1 or self.multiple_pressed:
return None
for key, val in self.pressed.items():
return max(0.0, val + self.repeat_delay - time.time()), key
return None
def sent_event(self) -> None:
"""
Cakk this method when you have sent a key repeat event so the
timer will be reset for the next event
"""
if len(self.pressed) != 1:
return # ignore event that shouldn't have been sent
for key in self.pressed:
self.pressed[key] = time.time() - self.repeat_delay + self.repeat_next
return
class CF635Screen(CFLCDScreen):
"""
Crystal Fontz 635 display
20x4 character display + cursor
no foreground/background colors or settings supported
see CGROM for a list of close Unicode matches to characters available
6 button input
up, down, left, right, enter (check mark), exit (cross)
"""
DISPLAY_SIZE = (20, 4)
# ① through ⑧ are programmable CGRAM (chars 0-7, repeated at 8-15)
# double arrows (⇑⇓) appear as double arrowheads (chars 18, 19)
# ⑴ resembles a bell
# ⑵ resembles a filled-in "Y"
# ⑶ is the letters "Pt" together
# partial blocks (▇▆▄▃▁) are actually shorter versions of (▉▋▌▍▏)
# both groups are intended to draw horizontal bars with pixel
# precision, use ▇*[▆▄▃▁]? for a thin bar or ▉*[▋▌▍▏]? for a thick bar
CGROM = (
"①②③④⑤⑥⑦⑧①②③④⑤⑥⑦⑧"
"►◄⇑⇓«»↖↗↙↘▲▼↲^ˇ█"
" !\"#¤%&'()*+,-./"
"0123456789:;<=>?"
"¡ABCDEFGHIJKLMNO"
"PQRSTUVWXYZÄÖÑܧ"
"¿abcdefghijklmno"
"pqrstuvwxyzäöñüà"
"⁰¹²³⁴⁵⁶⁷⁸⁹½¼±≥≤μ"
"♪♫⑴♥♦⑵⌜⌟“”()αɛδ∞"
"@£$¥èéùìòÇᴾØøʳÅå"
"⌂¢ΦτλΩπΨΣθΞ♈ÆæßÉ"
"ΓΛΠϒ_ÈÊêçğŞşİι~◊"
"▇▆▄▃▁ƒ▉▋▌▍▏⑶◽▪↑→"
"↓←ÁÍÓÚÝáíóúýÔôŮů"
r"ČĔŘŠŽčĕřšž[\]{|}"
)
cursor_style = CFLCDScreen.CURSOR_INVERTING_BLINKING_BLOCK
def __init__(
self,
device_path: str,
baud: int = 115200,
repeat_delay: float = 0.5,
repeat_next: float = 0.125,
key_map: Iterable[str] = ("up", "down", "left", "right", "enter", "esc"),
):
"""
device_path -- eg. '/dev/ttyUSB0'
baud -- baud rate
repeat_delay -- seconds to wait before starting to repeat keys
repeat_next -- time between each repeated key
key_map -- the keys to send for this device's buttons
"""
super().__init__(device_path, baud)
self.repeat_delay = repeat_delay
self.repeat_next = repeat_next
self.key_repeat = KeyRepeatSimulator(repeat_delay, repeat_next)
self.key_map = tuple(key_map)
self._last_command = None
self._last_command_time = 0
self._command_queue: list[tuple[int, bytearray]] = []
self._screen_buf = None
self._previous_canvas = None
self._update_cursor = False
def get_input_descriptors(self) -> list[int]:
"""
return the fd from our serial device so we get called
on input and responses
"""
return [self._device.fd]
def get_input_nonblocking(self) -> tuple[None, list[str], list[int]]:
"""
Return a (next_input_timeout, keys_pressed, raw_keycodes)
tuple.
The protocol for our device requires waiting for acks between
each command, so this method responds to those as well as key
press and release events.
Key repeat events are simulated here as the device doesn't send
any for us.
raw_keycodes are the bytes of messages we received, which might
not seem to have any correspondence to keys_pressed.
"""
data_input: list[str] = []
raw_data_input: list[int] = []
timeout = None
packet = self._read_packet()
while packet:
command, data = packet
if command == self.CMD_KEY_ACTIVITY and data:
d0 = data[0]
if 1 <= d0 <= 12:
release = d0 > 6
keycode = d0 - (release * 6) - 1
key = self.key_map[keycode]
if release:
self.key_repeat.release(key)
else:
data_input.append(key)
self.key_repeat.press(key)
raw_data_input.append(d0)
elif command & 0xC0 == 0x40 and command & 0x3F == self._last_command: # "ACK"
self._send_next_command()
packet = self._read_packet()
if next_repeat := self.key_repeat.next_event():
timeout, key = next_repeat
if not timeout:
data_input.append(key)
self.key_repeat.sent_event()
timeout = None
return timeout, data_input, raw_data_input
def _send_next_command(self) -> None:
"""
send out the next command in the queue
"""
if not self._command_queue:
self._last_command = None
return
command, data = self._command_queue.pop(0)
self._send_packet(command, data)
self._last_command = command # record command for ACK
self._last_command_time = time.time()
def queue_command(self, command: int, data: bytearray) -> None:
self._command_queue.append((command, data))
# not waiting? send away!
if self._last_command is None:
self._send_next_command()
def draw_screen(self, size: tuple[int, int], canvas: Canvas) -> None:
if size != self.DISPLAY_SIZE:
raise ValueError(size)
if self._screen_buf:
osb = self._screen_buf
else:
osb = []
sb = []
for y, row in enumerate(canvas.content()):
text = [run for _a, _cs, run in row]
if not osb or osb[y] != text:
data = bytearray([0, y])
for elem in text:
data.extend(elem)
self.queue_command(self.CMD_LCD_DATA, data)
sb.append(text)
if (
self._previous_canvas
and self._previous_canvas.cursor == canvas.cursor
and (not self._update_cursor or not canvas.cursor)
):
pass
elif canvas.cursor is None:
self.queue_command(self.CMD_CURSOR_STYLE, bytearray([self.CURSOR_NONE]))
else:
x, y = canvas.cursor
self.queue_command(self.CMD_CURSOR_POSITION, bytearray([x, y]))
self.queue_command(self.CMD_CURSOR_STYLE, bytearray([self.cursor_style]))
self._update_cursor = False
self._screen_buf = sb
self._previous_canvas = canvas
def program_cgram(self, index: int, data: Sequence[int]) -> None:
"""
Program character data.
Characters available as chr(0) through chr(7), and repeated as chr(8) through chr(15).
index -- 0 to 7 index of character to program
data -- list of 8, 6-bit integer values top to bottom with MSB on the left side of the character.
"""
if not 0 <= index <= 7:
raise ValueError(index)
if len(data) != 8:
raise ValueError(data)
self.queue_command(self.CMD_CGRAM, bytearray([index]) + bytearray(data))
def set_cursor_style(self, style: Literal[1, 2, 3, 4]) -> None:
"""
style -- CURSOR_BLINKING_BLOCK, CURSOR_UNDERSCORE,
CURSOR_BLINKING_BLOCK_UNDERSCORE or
CURSOR_INVERTING_BLINKING_BLOCK
"""
if not 1 <= style <= 4:
raise ValueError(style)
self.cursor_style = style
self._update_cursor = True
def set_backlight(self, value: int) -> None:
"""
Set backlight brightness
value -- 0 to 100
"""
if not 0 <= value <= 100:
raise ValueError(value)
self.queue_command(self.CMD_BACKLIGHT, bytearray([value]))
def set_lcd_contrast(self, value: int) -> None:
"""
value -- 0 to 255
"""
if not 0 <= value <= 255:
raise ValueError(value)
self.queue_command(self.CMD_LCD_CONTRAST, bytearray([value]))
def set_led_pin(self, led: Literal[0, 1, 2, 3], rg: Literal[0, 1], value: int) -> None:
"""
led -- 0 to 3
rg -- 0 for red, 1 for green
value -- 0 to 100
"""
if not 0 <= led <= 3:
raise ValueError(led)
if rg not in {0, 1}:
raise ValueError(rg)
if not 0 <= value <= 100:
raise ValueError(value)
self.queue_command(self.CMD_GPO, bytearray([12 - 2 * led - rg, value]))
@@ -0,0 +1,37 @@
# Urwid raw display module
# Copyright (C) 2004-2009 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
"""
Direct terminal UI implementation
"""
from __future__ import annotations
import sys
__all__ = ("Screen",)
IS_WINDOWS = sys.platform == "win32"
if IS_WINDOWS:
from ._win32_raw_display import Screen
else:
from ._posix_raw_display import Screen
@@ -0,0 +1,626 @@
# Urwid web (CGI/Asynchronous Javascript) display module
# Copyright (C) 2004-2007 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
"""
Urwid web application display module
"""
from __future__ import annotations
import dataclasses
import glob
import html
import os
import pathlib
import random
import selectors
import signal
import socket
import string
import sys
import tempfile
import typing
from contextlib import suppress
from urwid.str_util import calc_text_pos, calc_width, move_next_char
from urwid.util import StoppingContext, get_encoding
from .common import BaseScreen
if typing.TYPE_CHECKING:
from typing_extensions import Literal
from urwid import Canvas
TEMP_DIR = tempfile.gettempdir()
CURRENT_DIR = pathlib.Path(__file__).parent
_js_code = CURRENT_DIR.joinpath("_web.js").read_text("utf-8")
ALARM_DELAY = 60
POLL_CONNECT = 3
MAX_COLS = 200
MAX_ROWS = 100
MAX_READ = 4096
BUF_SZ = 16384
_code_colours = {
"black": "0",
"dark red": "1",
"dark green": "2",
"brown": "3",
"dark blue": "4",
"dark magenta": "5",
"dark cyan": "6",
"light gray": "7",
"dark gray": "8",
"light red": "9",
"light green": "A",
"yellow": "B",
"light blue": "C",
"light magenta": "D",
"light cyan": "E",
"white": "F",
}
# replace control characters with ?'s
_trans_table = "?" * 32 + "".join([chr(x) for x in range(32, 256)])
_css_style = CURRENT_DIR.joinpath("_web.css").read_text("utf-8")
# HTML Initial Page
_html_page = [
"""<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
"http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<title>Urwid Web Display - """,
"""</title>
<style type="text/css">
"""
+ _css_style
+ r"""
</style>
</head>
<body id="body" onload="load_web_display()">
<div style="position:absolute; visibility:hidden;">
<br id="br"\>
<pre>The quick brown fox jumps over the lazy dog.<span id="testchar">X</span>
<span id="testchar2">Y</span></pre>
</div>
Urwid Web Display - <b>""",
"""</b> -
Status: <span id="status">Set up</span>
<script type="text/javascript">
//<![CDATA[
"""
+ _js_code
+ """
//]]>
</script>
<pre id="text"></pre>
</body>
</html>
""",
]
class Screen(BaseScreen):
def __init__(self) -> None:
super().__init__()
self.palette = {}
self.has_color = True
self._started = False
@property
def started(self) -> bool:
return self._started
def register_palette(self, palette) -> None:
"""Register a list of palette entries.
palette -- list of (name, foreground, background) or
(name, same_as_other_name) palette entries.
calls self.register_palette_entry for each item in l
"""
for item in palette:
if len(item) in {3, 4}:
self.register_palette_entry(*item)
continue
if len(item) != 2:
raise ValueError(f"Invalid register_palette usage: {item!r}")
name, like_name = item
if like_name not in self.palette:
raise KeyError(f"palette entry '{like_name}' doesn't exist")
self.palette[name] = self.palette[like_name]
def register_palette_entry(
self,
name: str | None,
foreground: str,
background: str,
mono: str | None = None,
foreground_high: str | None = None,
background_high: str | None = None,
) -> None:
"""Register a single palette entry.
name -- new entry/attribute name
foreground -- foreground colour
background -- background colour
mono -- monochrome terminal attribute
See curses_display.register_palette_entry for more info.
"""
if foreground == "default":
foreground = "black"
if background == "default":
background = "light gray"
self.palette[name] = (foreground, background, mono)
def set_mouse_tracking(self, enable: bool = True) -> None:
"""Not yet implemented"""
def tty_signal_keys(self, *args, **vargs):
"""Do nothing."""
def start(self) -> StoppingContext:
"""
This function reads the initial screen size, generates a
unique id and handles cleanup when fn exits.
web_display.set_preferences(..) must be called before calling
this function for the preferences to take effect
"""
if self._started:
return StoppingContext(self)
client_init = sys.stdin.read(50)
if not client_init.startswith("window resize "):
raise ValueError(client_init)
_ignore1, _ignore2, x, y = client_init.split(" ", 3)
x = int(x)
y = int(y)
self._set_screen_size(x, y)
self.last_screen = {}
self.last_screen_width = 0
self.update_method = os.environ["HTTP_X_URWID_METHOD"]
if self.update_method not in {"multipart", "polling"}:
raise ValueError(self.update_method)
if self.update_method == "polling" and not _prefs.allow_polling:
sys.stdout.write("Status: 403 Forbidden\r\n\r\n")
sys.exit(0)
clients = glob.glob(os.path.join(_prefs.pipe_dir, "urwid*.in"))
if len(clients) >= _prefs.max_clients:
sys.stdout.write("Status: 503 Sever Busy\r\n\r\n")
sys.exit(0)
urwid_id = f"{random.randrange(10**9):09d}{random.randrange(10**9):09d}" # noqa: S311
self.pipe_name = os.path.join(_prefs.pipe_dir, f"urwid{urwid_id}")
os.mkfifo(f"{self.pipe_name}.in", 0o600)
signal.signal(signal.SIGTERM, self._cleanup_pipe)
self.input_fd = os.open(f"{self.pipe_name}.in", os.O_NONBLOCK | os.O_RDONLY)
self.input_tail = ""
self.content_head = (
f"Content-type: multipart/x-mixed-replace;boundary=ZZ\r\nX-Urwid-ID: {urwid_id}\r\n\r\n\r\n--ZZ\r\n"
)
if self.update_method == "polling":
self.content_head = f"Content-type: text/plain\r\nX-Urwid-ID: {urwid_id}\r\n\r\n\r\n"
signal.signal(signal.SIGALRM, self._handle_alarm)
signal.alarm(ALARM_DELAY)
self._started = True
return StoppingContext(self)
def stop(self) -> None:
"""
Restore settings and clean up.
"""
if not self._started:
return
# XXX which exceptions does this actually raise? EnvironmentError?
with suppress(Exception):
self._close_connection()
signal.signal(signal.SIGTERM, signal.SIG_DFL)
self._cleanup_pipe()
self._started = False
def set_input_timeouts(self, *args: typing.Any) -> None:
pass
def _close_connection(self) -> None:
if self.update_method == "polling child":
self.server_socket.settimeout(0)
sock, _addr = self.server_socket.accept()
sock.sendall(b"Z")
sock.close()
if self.update_method == "multipart":
sys.stdout.write("\r\nZ\r\n--ZZ--\r\n")
sys.stdout.flush()
def _cleanup_pipe(self, *args) -> None:
if not self.pipe_name:
return
# XXX which exceptions does this actually raise? EnvironmentError?
with suppress(Exception):
os.remove(f"{self.pipe_name}.in")
os.remove(f"{self.pipe_name}.update")
def _set_screen_size(self, cols: int, rows: int) -> None:
"""Set the screen size (within max size)."""
cols = min(cols, MAX_COLS)
rows = min(rows, MAX_ROWS)
self.screen_size = cols, rows
def draw_screen(self, size: tuple[int, int], canvas: Canvas) -> None:
"""Send a screen update to the client."""
(cols, rows) = size
if cols != self.last_screen_width:
self.last_screen = {}
sendq = [self.content_head]
if self.update_method == "polling":
send = sendq.append
elif self.update_method == "polling child":
signal.alarm(0)
try:
s, _addr = self.server_socket.accept()
except socket.timeout:
sys.exit(0)
send = s.sendall
else:
signal.alarm(0)
send = sendq.append
send("\r\n")
self.content_head = ""
if canvas.rows() != rows:
raise ValueError(rows)
if canvas.cursor is not None:
cx, cy = canvas.cursor
else:
cx = cy = None
new_screen = {}
y = -1
for row in canvas.content():
y += 1
l_row = tuple((attr_, line.decode(get_encoding())) for attr_, _, line in row)
line = []
sig = l_row
if y == cy:
sig = (*sig, cx)
new_screen[sig] = [*new_screen.get(sig, []), y]
if (old_line_numbers := self.last_screen.get(sig, None)) is not None:
if y in old_line_numbers:
old_line = y
else:
old_line = old_line_numbers[0]
send(f"<{old_line:d}\n")
continue
col = 0
for a, run in l_row:
t_run = run.translate(_trans_table)
if a is None:
fg, bg, _mono = "black", "light gray", None
else:
fg, bg, _mono = self.palette[a]
if y == cy and col <= cx:
run_width = calc_width(t_run, 0, len(t_run))
if col + run_width > cx:
line.append(code_span(t_run, fg, bg, cx - col))
else:
line.append(code_span(t_run, fg, bg))
col += run_width
else:
line.append(code_span(t_run, fg, bg))
send(f"{''.join(line)}\n")
self.last_screen = new_screen
self.last_screen_width = cols
if self.update_method == "polling":
sys.stdout.write("".join(sendq))
sys.stdout.flush()
sys.stdout.close()
self._fork_child()
elif self.update_method == "polling child":
s.close()
else: # update_method == "multipart"
send("\r\n--ZZ\r\n")
sys.stdout.write("".join(sendq))
sys.stdout.flush()
signal.alarm(ALARM_DELAY)
def clear(self) -> None:
"""
Force the screen to be completely repainted on the next
call to draw_screen().
(does nothing for web_display)
"""
def _fork_child(self) -> None:
"""
Fork a child to run CGI disconnected for polling update method.
Force parent process to exit.
"""
daemonize(f"{self.pipe_name}.err")
self.input_fd = os.open(f"{self.pipe_name}.in", os.O_NONBLOCK | os.O_RDONLY)
self.update_method = "polling child"
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.bind(f"{self.pipe_name}.update")
s.listen(1)
s.settimeout(POLL_CONNECT)
self.server_socket = s
def _handle_alarm(self, sig, frame) -> None:
if self.update_method not in {"multipart", "polling child"}:
raise ValueError(self.update_method)
if self.update_method == "polling child":
# send empty update
try:
s, _addr = self.server_socket.accept()
s.close()
except socket.timeout:
sys.exit(0)
else:
# send empty update
sys.stdout.write("\r\n\r\n--ZZ\r\n")
sys.stdout.flush()
signal.alarm(ALARM_DELAY)
def get_cols_rows(self) -> tuple[int, int]:
"""Return the screen size."""
return self.screen_size
@typing.overload
def get_input(self, raw_keys: Literal[False]) -> list[str]: ...
@typing.overload
def get_input(self, raw_keys: Literal[True]) -> tuple[list[str], list[int]]: ...
def get_input(self, raw_keys: bool = False) -> list[str] | tuple[list[str], list[int]]:
"""Return pending input as a list."""
pending_input = []
resized = False
with selectors.DefaultSelector() as selector:
selector.register(self.input_fd, selectors.EVENT_READ)
iready = [event.fd for event, _ in selector.select(0.5)]
if not iready:
if raw_keys:
return [], []
return []
keydata = os.read(self.input_fd, MAX_READ).decode(get_encoding())
os.close(self.input_fd)
self.input_fd = os.open(f"{self.pipe_name}.in", os.O_NONBLOCK | os.O_RDONLY)
# sys.stderr.write( repr((keydata,self.input_tail))+"\n" )
keys = keydata.split("\n")
keys[0] = self.input_tail + keys[0]
self.input_tail = keys[-1]
for k in keys[:-1]:
if k.startswith("window resize "):
_ign1, _ign2, x, y = k.split(" ", 3)
x = int(x)
y = int(y)
self._set_screen_size(x, y)
resized = True
else:
pending_input.append(k)
if resized:
pending_input.append("window resize")
if raw_keys:
return pending_input, []
return pending_input
def code_span(s: str, fg: str, bg: str, cursor: int = -1) -> str:
code_fg = _code_colours[fg]
code_bg = _code_colours[bg]
if cursor >= 0:
c_off, _ign = calc_text_pos(s, 0, len(s), cursor)
c2_off = move_next_char(s, c_off, len(s))
return (
code_fg
+ code_bg
+ s[:c_off]
+ "\n"
+ code_bg
+ code_fg
+ s[c_off:c2_off]
+ "\n"
+ code_fg
+ code_bg
+ s[c2_off:]
+ "\n"
)
return f"{code_fg + code_bg + s}\n"
def is_web_request() -> bool:
"""
Return True if this is a CGI web request.
"""
return "REQUEST_METHOD" in os.environ
def handle_short_request() -> bool:
"""
Handle short requests such as passing keystrokes to the application
or sending the initial HTML page. If returns True, then this
function recognized and handled a short request, and the calling
script should immediately exit.
web_display.set_preferences(..) should be called before calling this
function for the preferences to take effect
"""
if not is_web_request():
return False
if os.environ["REQUEST_METHOD"] == "GET":
# Initial request, send the HTML and javascript.
sys.stdout.write("Content-type: text/html\r\n\r\n" + html.escape(_prefs.app_name).join(_html_page))
return True
if os.environ["REQUEST_METHOD"] != "POST":
# Don't know what to do with head requests etc.
return False
if "HTTP_X_URWID_ID" not in os.environ:
# If no urwid id, then the application should be started.
return False
urwid_id = os.environ["HTTP_X_URWID_ID"]
if len(urwid_id) > 20:
# invalid. handle by ignoring
# assert 0, "urwid id too long!"
sys.stdout.write("Status: 414 URI Too Long\r\n\r\n")
return True
for c in urwid_id:
if c not in string.digits:
# invald. handle by ignoring
# assert 0, "invalid chars in id!"
sys.stdout.write("Status: 403 Forbidden\r\n\r\n")
return True
if os.environ.get("HTTP_X_URWID_METHOD", None) == "polling":
# this is a screen update request
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
s.connect(os.path.join(_prefs.pipe_dir, f"urwid{urwid_id}.update"))
data = f"Content-type: text/plain\r\n\r\n{s.recv(BUF_SZ)}"
while data:
sys.stdout.write(data)
data = s.recv(BUF_SZ)
except OSError:
sys.stdout.write("Status: 404 Not Found\r\n\r\n")
return True
return True
# this is a keyboard input request
try:
fd = os.open((os.path.join(_prefs.pipe_dir, f"urwid{urwid_id}.in")), os.O_WRONLY)
except OSError:
sys.stdout.write("Status: 404 Not Found\r\n\r\n")
return True
# FIXME: use the correct encoding based on the request
keydata = sys.stdin.read(MAX_READ)
os.write(fd, keydata.encode("ascii"))
os.close(fd)
sys.stdout.write("Content-type: text/plain\r\n\r\n")
return True
@dataclasses.dataclass
class _Preferences:
app_name: str = "Unnamed Application"
pipe_dir: str = TEMP_DIR
allow_polling: bool = True
max_clients: int = 20
_prefs = _Preferences()
def set_preferences(
app_name: str,
pipe_dir: str = TEMP_DIR,
allow_polling: bool = True,
max_clients: int = 20,
) -> None:
"""
Set web_display preferences.
app_name -- application name to appear in html interface
pipe_dir -- directory for input pipes, daemon update sockets
and daemon error logs
allow_polling -- allow creation of daemon processes for
browsers without multipart support
max_clients -- maximum concurrent client connections. This
pool is shared by all urwid applications
using the same pipe_dir
"""
_prefs.app_name = app_name
_prefs.pipe_dir = pipe_dir
_prefs.allow_polling = allow_polling
_prefs.max_clients = max_clients
class ErrorLog:
def __init__(self, errfile: str | pathlib.PurePath) -> None:
self.errfile = errfile
def write(self, err: str) -> None:
with open(self.errfile, "a", encoding="utf-8") as f:
f.write(err)
def daemonize(errfile: str) -> None:
"""
Detach process and become a daemon.
"""
if os.fork():
os._exit(0)
os.setsid()
signal.signal(signal.SIGHUP, signal.SIG_IGN)
os.umask(0)
if os.fork():
os._exit(0)
os.chdir("/")
for fd in range(0, 20):
with suppress(OSError):
os.close(fd)
sys.stdin = open("/dev/null", encoding="utf-8") # noqa: SIM115 # pylint: disable=consider-using-with
sys.stdout = open("/dev/null", "w", encoding="utf-8") # noqa: SIM115 # pylint: disable=consider-using-with
sys.stderr = ErrorLog(errfile)
@@ -0,0 +1,55 @@
"""Package with EventLoop implementations for urwid."""
from __future__ import annotations
import sys
from .abstract_loop import EventLoop, ExitMainLoop
from .asyncio_loop import AsyncioEventLoop
from .main_loop import MainLoop
from .select_loop import SelectEventLoop
__all__ = (
"AsyncioEventLoop",
"EventLoop",
"ExitMainLoop",
"MainLoop",
"SelectEventLoop",
)
try:
from .twisted_loop import TwistedEventLoop
__all__ += ("TwistedEventLoop",) # type: ignore[assignment]
except ImportError:
pass
try:
from .tornado_loop import TornadoEventLoop
__all__ += ("TornadoEventLoop",) # type: ignore[assignment]
except ImportError:
pass
try:
from .glib_loop import GLibEventLoop
__all__ += ("GLibEventLoop",) # type: ignore[assignment]
except ImportError:
pass
try:
from .trio_loop import TrioEventLoop
__all__ += ("TrioEventLoop",) # type: ignore[assignment]
except ImportError:
pass
if sys.platform != "win32":
# ZMQEventLoop cause interpreter crash on windows
try:
from .zmq_loop import ZMQEventLoop
__all__ += ("ZMQEventLoop",) # type: ignore[assignment]
except ImportError:
pass
@@ -0,0 +1,178 @@
# Urwid main loop code
# Copyright (C) 2004-2012 Ian Ward
# Copyright (C) 2008 Walter Mundt
# Copyright (C) 2009 Andrew Psaltis
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
"""Abstract shared code for urwid EventLoop implementation."""
from __future__ import annotations
import abc
import logging
import signal
import typing
if typing.TYPE_CHECKING:
import asyncio
from collections.abc import Callable
from concurrent.futures import Executor, Future
from types import FrameType
from typing_extensions import ParamSpec
_T = typing.TypeVar("_T")
_Spec = ParamSpec("_Spec")
__all__ = ("EventLoop", "ExitMainLoop")
class ExitMainLoop(Exception):
"""
When this exception is raised within a main loop the main loop
will exit cleanly.
"""
class EventLoop(abc.ABC):
"""
Abstract class representing an event loop to be used by :class:`MainLoop`.
"""
__slots__ = ("logger",)
def __init__(self) -> None:
self.logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
def run_in_executor(
self,
executor: Executor,
func: Callable[_Spec, _T],
*args: _Spec.args,
**kwargs: _Spec.kwargs,
) -> Future[_T] | asyncio.Future[_T]:
"""Run callable in executor if supported.
:param executor: Executor to use for running the function
:type executor: concurrent.futures.Executor
:param func: function to call
:type func: Callable
:param args: arguments to function (positional only)
:type args: object
:param kwargs: keyword arguments to function (keyword only)
:type kwargs: object
:return: future object for the function call outcome.
(exact future type depends on the event loop type)
:rtype: concurrent.futures.Future | asyncio.Future
"""
raise NotImplementedError
@abc.abstractmethod
def alarm(self, seconds: float, callback: Callable[[], typing.Any]) -> typing.Any:
"""
Call callback() a given time from now. No parameters are
passed to callback.
This method has no default implementation.
Returns a handle that may be passed to remove_alarm()
seconds -- floating point time to wait before calling callback
callback -- function to call from event loop
"""
@abc.abstractmethod
def enter_idle(self, callback):
"""
Add a callback for entering idle.
This method has no default implementation.
Returns a handle that may be passed to remove_idle()
"""
@abc.abstractmethod
def remove_alarm(self, handle) -> bool:
"""
Remove an alarm.
This method has no default implementation.
Returns True if the alarm exists, False otherwise
"""
@abc.abstractmethod
def remove_enter_idle(self, handle) -> bool:
"""
Remove an idle callback.
This method has no default implementation.
Returns True if the handle was removed.
"""
@abc.abstractmethod
def remove_watch_file(self, handle) -> bool:
"""
Remove an input file.
This method has no default implementation.
Returns True if the input file exists, False otherwise
"""
@abc.abstractmethod
def run(self) -> None:
"""
Start the event loop. Exit the loop when any callback raises
an exception. If ExitMainLoop is raised, exit cleanly.
This method has no default implementation.
"""
@abc.abstractmethod
def watch_file(self, fd: int, callback: Callable[[], typing.Any]):
"""
Call callback() when fd has some data to read. No parameters
are passed to callback.
This method has no default implementation.
Returns a handle that may be passed to remove_watch_file()
fd -- file descriptor to watch for input
callback -- function to call when input is available
"""
def set_signal_handler(
self,
signum: int,
handler: Callable[[int, FrameType | None], typing.Any] | int | signal.Handlers,
) -> Callable[[int, FrameType | None], typing.Any] | int | signal.Handlers | None:
"""
Sets the signal handler for signal signum.
The default implementation of :meth:`set_signal_handler`
is simply a proxy function that calls :func:`signal.signal()`
and returns the resulting value.
signum -- signal number
handler -- function (taking signum as its single argument),
or `signal.SIG_IGN`, or `signal.SIG_DFL`
"""
return signal.signal(signum, handler)
@@ -0,0 +1,253 @@
# Urwid main loop code
# Copyright (C) 2004-2012 Ian Ward
# Copyright (C) 2008 Walter Mundt
# Copyright (C) 2009 Andrew Psaltis
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
"""Asyncio based urwid EventLoop implementation."""
from __future__ import annotations
import asyncio
import functools
import logging
import sys
import typing
from .abstract_loop import EventLoop, ExitMainLoop
if typing.TYPE_CHECKING:
from collections.abc import Callable
from concurrent.futures import Executor
from typing_extensions import ParamSpec
_Spec = ParamSpec("_Spec")
_T = typing.TypeVar("_T")
__all__ = ("AsyncioEventLoop",)
class AsyncioEventLoop(EventLoop):
"""
Event loop based on the standard library ``asyncio`` module.
.. warning::
Under Windows, AsyncioEventLoop globally enforces WindowsSelectorEventLoopPolicy
as a side-effect of creating a class instance.
Original event loop policy is restored in destructor method.
.. note::
If you make any changes to the urwid state outside of it
handling input or responding to alarms (for example, from asyncio.Task
running in background), and wish the screen to be
redrawn, you must call :meth:`MainLoop.draw_screen` method of the
main loop manually.
A good way to do this:
asyncio.get_event_loop().call_soon(main_loop.draw_screen)
"""
def __init__(self, *, loop: asyncio.AbstractEventLoop | None = None, **kwargs) -> None:
super().__init__()
self.logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
if sys.version_info[:2] < (3, 11):
self._event_loop_policy_altered: bool = False
self._original_event_loop_policy: asyncio.AbstractEventLoopPolicy | None = None
if loop:
self._loop: asyncio.AbstractEventLoop = loop
else:
self._original_event_loop_policy = asyncio.get_event_loop_policy()
if sys.platform == "win32" and not isinstance(
self._original_event_loop_policy, asyncio.WindowsSelectorEventLoopPolicy
):
self.logger.debug("Set WindowsSelectorEventLoopPolicy as asyncio event loop policy")
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
self._event_loop_policy_altered = True
else:
self._event_loop_policy_altered = False
self._loop = asyncio.get_event_loop()
else:
self._runner: asyncio.Runner | None = None
if loop:
self._loop: asyncio.AbstractEventLoop = loop
else:
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
self._runner = asyncio.Runner(loop_factory=asyncio.SelectorEventLoop)
self._loop = self._runner.get_loop()
self._exc: BaseException | None = None
self._idle_asyncio_handle: asyncio.TimerHandle | None = None
self._idle_handle: int = 0
self._idle_callbacks: dict[int, Callable[[], typing.Any]] = {}
def __del__(self) -> None:
if sys.version_info[:2] < (3, 11):
if self._event_loop_policy_altered:
asyncio.set_event_loop_policy(self._original_event_loop_policy) # Restore default event loop policy
elif self._runner is not None:
self._runner.close()
def _also_call_idle(self, callback: Callable[_Spec, _T]) -> Callable[_Spec, _T]:
"""
Wrap the callback to also call _entering_idle.
"""
@functools.wraps(callback)
def wrapper(*args: _Spec.args, **kwargs: _Spec.kwargs) -> _T:
if not self._idle_asyncio_handle:
self._idle_asyncio_handle = self._loop.call_later(0, self._entering_idle)
return callback(*args, **kwargs)
return wrapper
def _entering_idle(self) -> None:
"""
Call all the registered idle callbacks.
"""
try:
for callback in self._idle_callbacks.values():
callback()
finally:
self._idle_asyncio_handle = None
def run_in_executor(
self,
executor: Executor | None,
func: Callable[_Spec, _T],
*args: _Spec.args,
**kwargs: _Spec.kwargs,
) -> asyncio.Future[_T]:
"""Run callable in executor.
:param executor: Executor to use for running the function. Default asyncio executor is used if None.
:type executor: concurrent.futures.Executor | None
:param func: function to call
:type func: Callable
:param args: arguments to function (positional only)
:type args: object
:param kwargs: keyword arguments to function (keyword only)
:type kwargs: object
:return: future object for the function call outcome.
:rtype: asyncio.Future
"""
return self._loop.run_in_executor(executor, functools.partial(func, *args, **kwargs))
def alarm(self, seconds: float, callback: Callable[[], typing.Any]) -> asyncio.TimerHandle:
"""
Call callback() a given time from now. No parameters are
passed to callback.
Returns a handle that may be passed to remove_alarm()
seconds -- time in seconds to wait before calling callback
callback -- function to call from event loop
"""
return self._loop.call_later(seconds, self._also_call_idle(callback))
def remove_alarm(self, handle) -> bool:
"""
Remove an alarm.
Returns True if the alarm exists, False otherwise
"""
existed = not handle.cancelled()
handle.cancel()
return existed
def watch_file(self, fd: int, callback: Callable[[], typing.Any]) -> int:
"""
Call callback() when fd has some data to read. No parameters
are passed to callback.
Returns a handle that may be passed to remove_watch_file()
fd -- file descriptor to watch for input
callback -- function to call when input is available
"""
self._loop.add_reader(fd, self._also_call_idle(callback))
return fd
def remove_watch_file(self, handle: int) -> bool:
"""
Remove an input file.
Returns True if the input file exists, False otherwise
"""
return self._loop.remove_reader(handle)
def enter_idle(self, callback: Callable[[], typing.Any]) -> int:
"""
Add a callback for entering idle.
Returns a handle that may be passed to remove_enter_idle()
"""
# XXX there's no such thing as "idle" in most event loops; this fakes
# it by adding extra callback to the timer and file watch callbacks.
self._idle_handle += 1
self._idle_callbacks[self._idle_handle] = callback
return self._idle_handle
def remove_enter_idle(self, handle: int) -> bool:
"""
Remove an idle callback.
Returns True if the handle was removed.
"""
try:
del self._idle_callbacks[handle]
except KeyError:
return False
return True
def _exception_handler(self, loop: asyncio.AbstractEventLoop, context):
if exc := context.get("exception"):
loop.stop()
if self._idle_asyncio_handle:
# clean it up to prevent old callbacks
# from messing things up if loop is restarted
self._idle_asyncio_handle.cancel()
self._idle_asyncio_handle = None
if not isinstance(exc, ExitMainLoop):
# Store the exc_info so we can re-raise after the loop stops
self._exc = exc
else:
loop.default_exception_handler(context)
def run(self) -> None:
"""Start the event loop.
Exit the loop when any callback raises an exception.
If ExitMainLoop is raised, exit cleanly.
"""
self._loop.set_exception_handler(self._exception_handler)
self._loop.run_forever()
if self._exc:
exc = self._exc
self._exc = None
raise exc.with_traceback(exc.__traceback__)
@@ -0,0 +1,294 @@
# Urwid main loop code
# Copyright (C) 2004-2012 Ian Ward
# Copyright (C) 2008 Walter Mundt
# Copyright (C) 2009 Andrew Psaltis
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
"""GLib based urwid EventLoop implementation.
PyGObject library is required.
"""
from __future__ import annotations
import functools
import logging
import signal
import typing
from gi.repository import GLib
from .abstract_loop import EventLoop, ExitMainLoop
if typing.TYPE_CHECKING:
from collections.abc import Callable
from concurrent.futures import Executor, Future
from types import FrameType
from typing_extensions import Literal, ParamSpec
_Spec = ParamSpec("_Spec")
_T = typing.TypeVar("_T")
__all__ = ("GLibEventLoop",)
def _ignore_handler(_sig: int, _frame: FrameType | None = None) -> None:
return None
class GLibEventLoop(EventLoop):
"""
Event loop based on GLib.MainLoop
"""
def __init__(self) -> None:
super().__init__()
self.logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
self._alarms: list[int] = []
self._watch_files: dict[int, int] = {}
self._idle_handle: int = 0
self._glib_idle_enabled = False # have we called glib.idle_add?
self._idle_callbacks: dict[int, Callable[[], typing.Any]] = {}
self._loop = GLib.MainLoop()
self._exc: BaseException | None = None
self._enable_glib_idle()
self._signal_handlers: dict[int, int] = {}
def run_in_executor(
self,
executor: Executor,
func: Callable[_Spec, _T],
*args: _Spec.args,
**kwargs: _Spec.kwargs,
) -> Future[_T]:
"""Run callable in executor.
:param executor: Executor to use for running the function
:type executor: concurrent.futures.Executor
:param func: function to call
:type func: Callable
:param args: positional arguments to function
:type args: object
:param kwargs: keyword arguments to function
:type kwargs: object
:return: future object for the function call outcome.
:rtype: concurrent.futures.Future
"""
return executor.submit(func, *args, **kwargs)
def alarm(
self,
seconds: float,
callback: Callable[[], typing.Any],
) -> tuple[int, Callable[[], typing.Any]]:
"""
Call callback() a given time from now. No parameters are
passed to callback.
Returns a handle that may be passed to remove_alarm()
seconds -- floating point time to wait before calling callback
callback -- function to call from event loop
"""
@self.handle_exit
def ret_false() -> Literal[False]:
callback()
self._enable_glib_idle()
return False
fd = GLib.timeout_add(int(seconds * 1000), ret_false)
self._alarms.append(fd)
return (fd, callback)
def set_signal_handler(
self,
signum: int,
handler: Callable[[int, FrameType | None], typing.Any] | int | signal.Handlers,
) -> None:
"""
Sets the signal handler for signal signum.
.. WARNING::
Because this method uses the `GLib`-specific `unix_signal_add`
function, its behaviour is different than `signal.signal().`
If `signum` is not `SIGHUP`, `SIGINT`, `SIGTERM`, `SIGUSR1`,
`SIGUSR2` or `SIGWINCH`, this method performs no actions and
immediately returns None.
Returns None in all cases (unlike :func:`signal.signal()`).
..
signum -- signal number
handler -- function (taking signum as its single argument),
or `signal.SIG_IGN`, or `signal.SIG_DFL`
"""
glib_signals = [
signal.SIGHUP,
signal.SIGINT,
signal.SIGTERM,
signal.SIGUSR1,
signal.SIGUSR2,
]
# GLib supports SIGWINCH as of version 2.54.
if not GLib.check_version(2, 54, 0):
glib_signals.append(signal.SIGWINCH)
if signum not in glib_signals:
# The GLib event loop supports only the signals listed above
return
if signum in self._signal_handlers:
GLib.source_remove(self._signal_handlers.pop(signum))
if handler == signal.Handlers.SIG_IGN:
handler = _ignore_handler
elif handler == signal.Handlers.SIG_DFL:
return
def final_handler(signal_number: int):
# MyPy False-negative: signal.Handlers casted
handler(signal_number, None) # type: ignore[operator]
return GLib.SOURCE_CONTINUE
source = GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signum, final_handler, signum)
self._signal_handlers[signum] = source
def remove_alarm(self, handle) -> bool:
"""
Remove an alarm.
Returns True if the alarm exists, False otherwise
"""
try:
self._alarms.remove(handle[0])
GLib.source_remove(handle[0])
except ValueError:
return False
return True
def watch_file(self, fd: int, callback: Callable[[], typing.Any]) -> int:
"""
Call callback() when fd has some data to read. No parameters
are passed to callback.
Returns a handle that may be passed to remove_watch_file()
fd -- file descriptor to watch for input
callback -- function to call when input is available
"""
@self.handle_exit
def io_callback(source, cb_condition) -> Literal[True]:
callback()
self._enable_glib_idle()
return True
self._watch_files[fd] = GLib.io_add_watch(fd, GLib.IO_IN, io_callback)
return fd
def remove_watch_file(self, handle: int) -> bool:
"""
Remove an input file.
Returns True if the input file exists, False otherwise
"""
if handle in self._watch_files:
GLib.source_remove(self._watch_files[handle])
del self._watch_files[handle]
return True
return False
def enter_idle(self, callback: Callable[[], typing.Any]) -> int:
"""
Add a callback for entering idle.
Returns a handle that may be passed to remove_enter_idle()
"""
self._idle_handle += 1
self._idle_callbacks[self._idle_handle] = callback
return self._idle_handle
def _enable_glib_idle(self) -> None:
if self._glib_idle_enabled:
return
GLib.idle_add(self._glib_idle_callback)
self._glib_idle_enabled = True
def _glib_idle_callback(self):
for callback in self._idle_callbacks.values():
callback()
self._glib_idle_enabled = False
return False # ask glib not to call again (or we would be called
def remove_enter_idle(self, handle) -> bool:
"""
Remove an idle callback.
Returns True if the handle was removed.
"""
try:
del self._idle_callbacks[handle]
except KeyError:
return False
return True
def run(self) -> None:
"""
Start the event loop. Exit the loop when any callback raises
an exception. If ExitMainLoop is raised, exit cleanly.
"""
try:
self._loop.run()
finally:
if self._loop.is_running():
self._loop.quit()
if self._exc:
# An exception caused us to exit, raise it now
exc = self._exc
self._exc = None
raise exc.with_traceback(exc.__traceback__)
def handle_exit(self, f: Callable[_Spec, _T]) -> Callable[_Spec, _T | Literal[False]]:
"""
Decorator that cleanly exits the :class:`GLibEventLoop` if
:exc:`ExitMainLoop` is thrown inside of the wrapped function. Store the
exception info if some other exception occurs, it will be reraised after
the loop quits.
*f* -- function to be wrapped
"""
@functools.wraps(f)
def wrapper(*args: _Spec.args, **kwargs: _Spec.kwargs) -> _T | Literal[False]:
try:
return f(*args, **kwargs)
except ExitMainLoop:
self._loop.quit()
except BaseException as exc:
self._exc = exc
if self._loop.is_running():
self._loop.quit()
return False
return wrapper
@@ -0,0 +1,716 @@
# Urwid main loop code
# Copyright (C) 2004-2012 Ian Ward
# Copyright (C) 2008 Walter Mundt
# Copyright (C) 2009 Andrew Psaltis
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
import heapq
import logging
import os
import sys
import time
import typing
import warnings
from contextlib import suppress
from urwid import display, signals
from urwid.command_map import Command, command_map
from urwid.display.common import INPUT_DESCRIPTORS_CHANGED
from urwid.util import StoppingContext, is_mouse_event
from urwid.widget import PopUpTarget
from .abstract_loop import ExitMainLoop
from .select_loop import SelectEventLoop
if typing.TYPE_CHECKING:
from collections.abc import Callable, Iterable
from typing_extensions import Self
from urwid.display import BaseScreen
from urwid.widget import Widget
from .abstract_loop import EventLoop
_T = typing.TypeVar("_T")
IS_WINDOWS = sys.platform == "win32"
PIPE_BUFFER_READ_SIZE = 4096 # can expect this much on Linux, so try for that
__all__ = ("CantUseExternalLoop", "MainLoop")
class CantUseExternalLoop(Exception):
pass
class MainLoop:
"""
This is the standard main loop implementation for a single interactive
session.
:param widget: the topmost widget used for painting the screen, stored as
:attr:`widget` and may be modified. Must be a box widget.
:type widget: widget instance
:param palette: initial palette for screen
:type palette: iterable of palette entries
:param screen: screen to use, default is a new :class:`raw_display.Screen`
instance; stored as :attr:`screen`
:type screen: display module screen instance
:param handle_mouse: ``True`` to ask :attr:`.screen` to process mouse events
:type handle_mouse: bool
:param input_filter: a function to filter input before sending it to
:attr:`.widget`, called from :meth:`.input_filter`
:type input_filter: callable
:param unhandled_input: a function called when input is not handled by
:attr:`.widget`, called from :meth:`.unhandled_input`
:type unhandled_input: callable
:param event_loop: if :attr:`.screen` supports external an event loop it may be
given here, default is a new :class:`SelectEventLoop` instance;
stored as :attr:`.event_loop`
:type event_loop: event loop instance
:param pop_ups: `True` to wrap :attr:`.widget` with a :class:`PopUpTarget`
instance to allow any widget to open a pop-up anywhere on the screen
:type pop_ups: boolean
.. attribute:: screen
The screen object this main loop uses for screen updates and reading input
.. attribute:: event_loop
The event loop object this main loop uses for waiting on alarms and IO
"""
def __init__(
self,
widget: Widget,
palette: Iterable[
tuple[str, str] | tuple[str, str, str] | tuple[str, str, str, str] | tuple[str, str, str, str, str, str]
] = (),
screen: BaseScreen | None = None,
handle_mouse: bool = True,
input_filter: Callable[[list[str], list[int]], list[str]] | None = None,
unhandled_input: Callable[[str | tuple[str, int, int, int]], bool | None] | None = None,
event_loop: EventLoop | None = None,
pop_ups: bool = False,
):
self.logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
self._widget = widget
self.handle_mouse = handle_mouse
self._pop_ups = False # only initialize placeholder
self.pop_ups = pop_ups # triggers property setting side-effect
if not screen:
screen = display.raw.Screen()
if palette:
screen.register_palette(palette)
self.screen: BaseScreen = screen
self.screen_size: tuple[int, int] | None = None
self._unhandled_input = unhandled_input
self._input_filter = input_filter
if not hasattr(screen, "hook_event_loop") and event_loop is not None:
raise NotImplementedError(f"screen object passed {screen!r} does not support external event loops")
if event_loop is None:
event_loop = SelectEventLoop()
self.event_loop: EventLoop = event_loop
if hasattr(self.screen, "signal_handler_setter"):
# Tell the screen what function it must use to set
# signal handlers
self.screen.signal_handler_setter = self.event_loop.set_signal_handler
self._watch_pipes: dict[int, tuple[Callable[[], typing.Any], int]] = {}
@property
def widget(self) -> Widget:
"""
Property for the topmost widget used to draw the screen.
This must be a box widget.
"""
return self._widget
@widget.setter
def widget(self, widget: Widget) -> None:
self._widget = widget
if self.pop_ups:
self._topmost_widget.original_widget = self._widget
else:
self._topmost_widget = self._widget
def _set_widget(self, widget: Widget) -> None:
warnings.warn(
f"method `{self.__class__.__name__}._set_widget` is deprecated, "
f"please use `{self.__class__.__name__}.widget` property."
"API will be removed in version 4.0.",
DeprecationWarning,
stacklevel=2,
)
self.widget = widget
@property
def pop_ups(self) -> bool:
return self._pop_ups
@pop_ups.setter
def pop_ups(self, pop_ups: bool) -> None:
self._pop_ups = pop_ups
if pop_ups:
self._topmost_widget = PopUpTarget(self._widget)
else:
self._topmost_widget = self._widget
def _set_pop_ups(self, pop_ups: bool) -> None:
warnings.warn(
f"method `{self.__class__.__name__}._set_pop_ups` is deprecated, "
f"please use `{self.__class__.__name__}.pop_ups` property."
"API will be removed in version 4.0.",
DeprecationWarning,
stacklevel=2,
)
self.pop_ups = pop_ups
def set_alarm_in(self, sec: float, callback: Callable[[Self, _T], typing.Any], user_data: _T = None):
"""
Schedule an alarm in *sec* seconds that will call *callback* from the
within the :meth:`run` method.
:param sec: seconds until alarm
:type sec: float
:param callback: function to call with two parameters: this main loop
object and *user_data*
:type callback: callable
:param user_data: optional user data to pass to the callback
:type user_data: object
"""
self.logger.debug(f"Setting alarm in {sec!r} seconds with callback {callback!r}")
def cb() -> None:
callback(self, user_data)
return self.event_loop.alarm(sec, cb)
def set_alarm_at(self, tm: float, callback: Callable[[Self, _T], typing.Any], user_data: _T = None):
"""
Schedule an alarm at *tm* time that will call *callback* from the
within the :meth:`run` function. Returns a handle that may be passed to
:meth:`remove_alarm`.
:param tm: time to call callback e.g. ``time.time() + 5``
:type tm: float
:param callback: function to call with two parameters: this main loop
object and *user_data*
:type callback: callable
:param user_data: optional user data to pass to the callback
:type user_data: object
"""
sec = tm - time.time()
self.logger.debug(f"Setting alarm in {sec!r} seconds with callback {callback!r}")
def cb() -> None:
callback(self, user_data)
return self.event_loop.alarm(sec, cb)
def remove_alarm(self, handle) -> bool:
"""
Remove an alarm. Return ``True`` if *handle* was found, ``False``
otherwise.
"""
return self.event_loop.remove_alarm(handle)
if not IS_WINDOWS:
def watch_pipe(self, callback: Callable[[bytes], bool | None]) -> int:
"""
Create a pipe for use by a subprocess or thread to trigger a callback
in the process/thread running the main loop.
:param callback: function taking one parameter to call from within the process/thread running the main loop
:type callback: callable
This method returns a file descriptor attached to the write end of a pipe.
The read end of the pipe is added to the list of files :attr:`event_loop` is watching.
When data is written to the pipe the callback function will be called
and passed a single value containing data read from the pipe.
This method may be used any time you want to update widgets from another thread or subprocess.
Data may be written to the returned file descriptor with ``os.write(fd, data)``.
Ensure that data is less than 512 bytes (or 4K on Linux)
so that the callback will be triggered just once with the complete value of data passed in.
If the callback returns ``False`` then the watch will be removed from :attr:`event_loop`
and the read end of the pipe will be closed.
You are responsible for closing the write end of the pipe with ``os.close(fd)``.
"""
import fcntl
pipe_rd, pipe_wr = os.pipe()
fcntl.fcntl(pipe_rd, fcntl.F_SETFL, os.O_NONBLOCK)
watch_handle = None
def cb() -> None:
data = os.read(pipe_rd, PIPE_BUFFER_READ_SIZE)
if callback(data) is False:
self.event_loop.remove_watch_file(watch_handle)
os.close(pipe_rd)
watch_handle = self.event_loop.watch_file(pipe_rd, cb)
self._watch_pipes[pipe_wr] = (watch_handle, pipe_rd)
return pipe_wr
def remove_watch_pipe(self, write_fd: int) -> bool:
"""
Close the read end of the pipe and remove the watch created by :meth:`watch_pipe`.
..note:: You are responsible for closing the write end of the pipe.
Returns ``True`` if the watch pipe exists, ``False`` otherwise
"""
try:
watch_handle, pipe_rd = self._watch_pipes.pop(write_fd)
except KeyError:
return False
if not self.event_loop.remove_watch_file(watch_handle):
return False
os.close(pipe_rd)
return True
def watch_file(self, fd: int, callback: Callable[[], typing.Any]):
"""
Call *callback* when *fd* has some data to read. No parameters are
passed to callback.
Returns a handle that may be passed to :meth:`remove_watch_file`.
"""
self.logger.debug(f"Setting watch file descriptor {fd!r} with {callback!r}")
return self.event_loop.watch_file(fd, callback)
def remove_watch_file(self, handle):
"""
Remove a watch file. Returns ``True`` if the watch file
exists, ``False`` otherwise.
"""
return self.event_loop.remove_watch_file(handle)
def run(self) -> None:
"""
Start the main loop handling input events and updating the screen. The
loop will continue until an :exc:`ExitMainLoop` exception is raised.
If you would prefer to manage the event loop yourself, don't use this
method. Instead, call :meth:`start` before starting the event loop,
and :meth:`stop` once it's finished.
"""
with suppress(ExitMainLoop):
self._run()
def _test_run(self):
"""
>>> w = _refl("widget") # _refl prints out function calls
>>> w.render_rval = "fake canvas" # *_rval is used for return values
>>> scr = _refl("screen")
>>> scr.get_input_descriptors_rval = [42]
>>> scr.get_cols_rows_rval = (20, 10)
>>> scr.started = True
>>> scr._urwid_signals = {}
>>> evl = _refl("event_loop")
>>> evl.enter_idle_rval = 1
>>> evl.watch_file_rval = 2
>>> ml = MainLoop(w, [], scr, event_loop=evl)
>>> ml.run() # doctest:+ELLIPSIS
screen.start()
screen.set_mouse_tracking()
screen.unhook_event_loop(...)
screen.hook_event_loop(...)
event_loop.enter_idle(<bound method MainLoop.entering_idle...>)
event_loop.run()
event_loop.remove_enter_idle(1)
screen.unhook_event_loop(...)
screen.stop()
>>> ml.draw_screen() # doctest:+ELLIPSIS
screen.get_cols_rows()
widget.render((20, 10), focus=True)
screen.draw_screen((20, 10), 'fake canvas')
"""
def start(self) -> StoppingContext:
"""
Sets up the main loop, hooking into the event loop where necessary.
Starts the :attr:`screen` if it hasn't already been started.
If you want to control starting and stopping the event loop yourself,
you should call this method before starting, and call `stop` once the
loop has finished. You may also use this method as a context manager,
which will stop the loop automatically at the end of the block:
with main_loop.start():
...
Note that some event loop implementations don't handle exceptions
specially if you manage the event loop yourself. In particular, the
Twisted and asyncio loops won't stop automatically when
:exc:`ExitMainLoop` (or anything else) is raised.
"""
self.logger.debug(f"Starting event loop {self.event_loop.__class__.__name__!r} to manage display.")
self.screen.start()
if self.handle_mouse:
self.screen.set_mouse_tracking()
if not hasattr(self.screen, "hook_event_loop"):
raise CantUseExternalLoop(f"Screen {self.screen!r} doesn't support external event loops")
with suppress(NameError):
signals.connect_signal(self.screen, INPUT_DESCRIPTORS_CHANGED, self._reset_input_descriptors)
# watch our input descriptors
self._reset_input_descriptors()
self.idle_handle = self.event_loop.enter_idle(self.entering_idle)
# the screen is redrawn automatically after input and alarms,
# however, there can be none of those at the start,
# so draw the initial screen here unconditionally
self.event_loop.alarm(0, self.entering_idle)
return StoppingContext(self)
def stop(self) -> None:
"""
Cleans up any hooks added to the event loop. Only call this if you're
managing the event loop yourself, after the loop stops.
"""
self.event_loop.remove_enter_idle(self.idle_handle)
del self.idle_handle
signals.disconnect_signal(self.screen, INPUT_DESCRIPTORS_CHANGED, self._reset_input_descriptors)
self.screen.unhook_event_loop(self.event_loop)
self.screen.stop()
def _reset_input_descriptors(self) -> None:
self.screen.unhook_event_loop(self.event_loop)
self.screen.hook_event_loop(self.event_loop, self._update)
def _run(self) -> None:
try:
self.start()
except CantUseExternalLoop:
try:
self._run_screen_event_loop()
return
finally:
self.screen.stop()
try:
self.event_loop.run()
except:
self.screen.stop() # clean up screen control
raise
self.stop()
def _update(self, keys: list[str], raw: list[int]) -> None:
"""
>>> w = _refl("widget")
>>> w.selectable_rval = True
>>> w.mouse_event_rval = True
>>> scr = _refl("screen")
>>> scr.get_cols_rows_rval = (15, 5)
>>> evl = _refl("event_loop")
>>> ml = MainLoop(w, [], scr, event_loop=evl)
>>> ml._input_timeout = "old timeout"
>>> ml._update(['y'], [121]) # doctest:+ELLIPSIS
screen.get_cols_rows()
widget.selectable()
widget.keypress((15, 5), 'y')
>>> ml._update([("mouse press", 1, 5, 4)], [])
widget.mouse_event((15, 5), 'mouse press', 1, 5, 4, focus=True)
>>> ml._update([], [])
"""
if keys := self.input_filter(keys, raw):
self.process_input(keys)
if "window resize" in keys:
self.screen_size = None
def _run_screen_event_loop(self) -> None:
"""
This method is used when the screen does not support using external event loops.
The alarms stored in the SelectEventLoop in :attr:`event_loop` are modified by this method.
"""
# pylint: disable=protected-access # special case for alarms handling
self.logger.debug(f"Starting screen {self.screen!r} event loop")
next_alarm = None
while True:
self.draw_screen()
if not next_alarm and self.event_loop._alarms:
next_alarm = heapq.heappop(self.event_loop._alarms)
keys: list[str] = []
raw: list[int] = []
while not keys:
if next_alarm:
sec = max(0.0, next_alarm[0] - time.time())
self.screen.set_input_timeouts(sec)
else:
self.screen.set_input_timeouts(None)
keys, raw = self.screen.get_input(True)
if not keys and next_alarm and next_alarm[0] - time.time() <= 0:
break
if keys := self.input_filter(keys, raw):
self.process_input(keys)
while next_alarm:
if (next_alarm[0] - time.time()) > 0:
break
_tm, _tie_break, callback = next_alarm
callback()
if self.event_loop._alarms:
next_alarm = heapq.heappop(self.event_loop._alarms)
else:
next_alarm = None
if "window resize" in keys:
self.screen_size = None
def _test_run_screen_event_loop(self):
"""
>>> w = _refl("widget")
>>> scr = _refl("screen")
>>> scr.get_cols_rows_rval = (10, 5)
>>> scr.get_input_rval = [], []
>>> ml = MainLoop(w, screen=scr)
>>> def stop_now(loop, data):
... raise ExitMainLoop()
>>> handle = ml.set_alarm_in(0, stop_now)
>>> try:
... ml._run_screen_event_loop()
... except ExitMainLoop:
... pass
screen.get_cols_rows()
widget.render((10, 5), focus=True)
screen.draw_screen((10, 5), None)
screen.set_input_timeouts(0.0)
screen.get_input(True)
"""
def process_input(self, keys: Iterable[str | tuple[str, int, int, int]]) -> bool:
"""
This method will pass keyboard input and mouse events to :attr:`widget`.
This method is called automatically from the :meth:`run` method when
there is input, but may also be called to simulate input from the user.
*keys* is a list of input returned from :attr:`screen`'s get_input()
or get_input_nonblocking() methods.
Returns ``True`` if any key was handled by a widget or the
:meth:`unhandled_input` method.
"""
self.logger.debug(f"Processing input: keys={keys!r}")
if not self.screen_size:
self.screen_size = self.screen.get_cols_rows()
something_handled = False
for key in keys:
if key == "window resize":
continue
if isinstance(key, str):
if self._topmost_widget.selectable():
if handled_key := self._topmost_widget.keypress(self.screen_size, key):
key = handled_key # noqa: PLW2901
else:
something_handled = True
continue
elif is_mouse_event(key):
event, button, col, row = key
if hasattr(self._topmost_widget, "mouse_event") and self._topmost_widget.mouse_event(
self.screen_size,
event,
button,
col,
row,
focus=True,
):
something_handled = True
continue
else:
raise TypeError(f"{key!r} is not str | tuple[str, int, int, int]")
if key:
if command_map[key] == Command.REDRAW_SCREEN:
self.screen.clear()
something_handled = True
else:
something_handled |= bool(self.unhandled_input(key))
else:
something_handled = True
return something_handled
def _test_process_input(self):
"""
>>> w = _refl("widget")
>>> w.selectable_rval = True
>>> scr = _refl("screen")
>>> scr.get_cols_rows_rval = (10, 5)
>>> ml = MainLoop(w, [], scr)
>>> ml.process_input(['enter', ('mouse drag', 1, 14, 20)])
screen.get_cols_rows()
widget.selectable()
widget.keypress((10, 5), 'enter')
widget.mouse_event((10, 5), 'mouse drag', 1, 14, 20, focus=True)
True
"""
def input_filter(self, keys: list[str], raw: list[int]) -> list[str]:
"""
This function is passed each all the input events and raw keystroke
values. These values are passed to the *input_filter* function
passed to the constructor. That function must return a list of keys to
be passed to the widgets to handle. If no *input_filter* was
defined this implementation will return all the input events.
"""
if self._input_filter:
return self._input_filter(keys, raw)
return keys
def unhandled_input(self, data: str | tuple[str, int, int, int]) -> bool | None:
"""
This function is called with any input that was not handled by the
widgets, and calls the *unhandled_input* function passed to the
constructor. If no *unhandled_input* was defined then the input
will be ignored.
*input* is the keyboard or mouse input.
The *unhandled_input* function should return ``True`` if it handled
the input.
"""
if self._unhandled_input:
return self._unhandled_input(data)
return False
def entering_idle(self) -> None:
"""
This method is called whenever the event loop is about to enter the
idle state. :meth:`draw_screen` is called here to update the
screen when anything has changed.
"""
if self.screen.started:
self.draw_screen()
else:
self.logger.debug(f"No redrawing screen: {self.screen!r} is not started.")
def draw_screen(self) -> None:
"""
Render the widgets and paint the screen. This method is called
automatically from :meth:`entering_idle`.
If you modify the widgets displayed outside of handling input or
responding to an alarm you will need to call this method yourself
to repaint the screen.
"""
if not self.screen_size:
self.screen_size = self.screen.get_cols_rows()
self.logger.debug(f"Screen size recalculated: {self.screen_size!r}")
canvas = self._topmost_widget.render(self.screen_size, focus=True)
self.screen.draw_screen(self.screen_size, canvas)
def _refl(name: str, rval=None, loop_exit=False):
"""
This function is used to test the main loop classes.
>>> scr = _refl("screen")
>>> scr.function("argument")
screen.function('argument')
>>> scr.callme(when="now")
screen.callme(when='now')
>>> scr.want_something_rval = 42
>>> x = scr.want_something()
screen.want_something()
>>> x
42
"""
class Reflect:
def __init__(self, name: str, rval=None):
self._name = name
self._rval = rval
def __call__(self, *argl, **argd):
args = ", ".join([repr(a) for a in argl])
if args and argd:
args = f"{args}, "
args += ", ".join([f"{k}={v!r}" for k, v in argd.items()])
print(f"{self._name}({args})")
if loop_exit:
raise ExitMainLoop()
return self._rval
def __getattr__(self, attr):
if attr.endswith("_rval"):
raise AttributeError()
# print(self._name+"."+attr)
if hasattr(self, f"{attr}_rval"):
return Reflect(f"{self._name}.{attr}", getattr(self, f"{attr}_rval"))
return Reflect(f"{self._name}.{attr}")
return Reflect(name)
def _test():
import doctest
doctest.testmod()
if __name__ == "__main__":
_test()
@@ -0,0 +1,230 @@
# Urwid main loop code
# Copyright (C) 2004-2012 Ian Ward
# Copyright (C) 2008 Walter Mundt
# Copyright (C) 2009 Andrew Psaltis
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
"""Select based urwid EventLoop implementation."""
from __future__ import annotations
import contextlib
import heapq
import logging
import selectors
import time
import typing
from contextlib import suppress
from itertools import count
from .abstract_loop import EventLoop, ExitMainLoop
if typing.TYPE_CHECKING:
from collections.abc import Callable, Iterator
from concurrent.futures import Executor, Future
from typing_extensions import Literal, ParamSpec
_T = typing.TypeVar("_T")
_Spec = ParamSpec("_Spec")
__all__ = ("SelectEventLoop",)
class SelectEventLoop(EventLoop):
"""
Event loop based on :func:`selectors.DefaultSelector.select`
"""
def __init__(self) -> None:
super().__init__()
self.logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
self._alarms: list[tuple[float, int, Callable[[], typing.Any]]] = []
self._watch_files: dict[int, Callable[[], typing.Any]] = {}
self._idle_handle: int = 0
self._idle_callbacks: dict[int, Callable[[], typing.Any]] = {}
self._tie_break: Iterator[int] = count()
self._did_something: bool = False
def run_in_executor(
self,
executor: Executor,
func: Callable[_Spec, _T],
*args: _Spec.args,
**kwargs: _Spec.kwargs,
) -> Future[_T]:
"""Run callable in executor.
:param executor: Executor to use for running the function
:type executor: concurrent.futures.Executor
:param func: function to call
:type func: Callable
:param args: positional arguments to function
:type args: object
:param kwargs: keyword arguments to function
:type kwargs: object
:return: future object for the function call outcome.
:rtype: concurrent.futures.Future
"""
return executor.submit(func, *args, **kwargs)
def alarm(
self,
seconds: float,
callback: Callable[[], typing.Any],
) -> tuple[float, int, Callable[[], typing.Any]]:
"""
Call callback() a given time from now. No parameters are
passed to callback.
Returns a handle that may be passed to remove_alarm()
seconds -- floating point time to wait before calling callback
callback -- function to call from event loop
"""
tm = time.time() + seconds
handle = (tm, next(self._tie_break), callback)
heapq.heappush(self._alarms, handle)
return handle
def remove_alarm(self, handle: tuple[float, int, Callable[[], typing.Any]]) -> bool:
"""
Remove an alarm.
Returns True if the alarm exists, False otherwise
"""
try:
self._alarms.remove(handle)
heapq.heapify(self._alarms)
except ValueError:
return False
return True
def watch_file(self, fd: int, callback: Callable[[], typing.Any]) -> int:
"""
Call callback() when fd has some data to read. No parameters
are passed to callback.
Returns a handle that may be passed to remove_watch_file()
fd -- file descriptor to watch for input
callback -- function to call when input is available
"""
self._watch_files[fd] = callback
return fd
def remove_watch_file(self, handle: int) -> bool:
"""
Remove an input file.
Returns True if the input file exists, False otherwise
"""
if handle in self._watch_files:
del self._watch_files[handle]
return True
return False
def enter_idle(self, callback: Callable[[], typing.Any]) -> int:
"""
Add a callback for entering idle.
Returns a handle that may be passed to remove_idle()
"""
self._idle_handle += 1
self._idle_callbacks[self._idle_handle] = callback
return self._idle_handle
def remove_enter_idle(self, handle: int) -> bool:
"""
Remove an idle callback.
Returns True if the handle was removed.
"""
try:
del self._idle_callbacks[handle]
except KeyError:
return False
return True
def _entering_idle(self) -> None:
"""
Call all the registered idle callbacks.
"""
for callback in self._idle_callbacks.values():
callback()
def run(self) -> None:
"""
Start the event loop. Exit the loop when any callback raises
an exception. If ExitMainLoop is raised, exit cleanly.
"""
with contextlib.suppress(ExitMainLoop):
self._did_something = True
while True:
with suppress(InterruptedError):
self._loop()
def _loop(self) -> None:
"""
A single iteration of the event loop
"""
tm: float | Literal["idle"] | None = None
with selectors.DefaultSelector() as selector:
for fd, callback in self._watch_files.items():
selector.register(fd, selectors.EVENT_READ, callback)
if self._alarms or self._did_something:
timeout = 0.0
if self._alarms:
timeout_ = self._alarms[0][0]
tm = timeout_
timeout = max(timeout, timeout_ - time.time())
if self._did_something and (not self._alarms or (self._alarms and timeout > 0)):
timeout = 0.0
tm = "idle"
self.logger.debug(f"Waiting for input: timeout={timeout!r}")
ready = [event for event, _ in selector.select(timeout)]
elif self._watch_files:
self.logger.debug("Waiting for input: timeout")
ready = [event for event, _ in selector.select()]
else:
ready = []
if not ready:
if tm == "idle":
self.logger.debug("No input, entering IDLE")
self._entering_idle()
self._did_something = False
elif tm is not None:
# must have been a timeout
tm, _tie_break, alarm_callback = heapq.heappop(self._alarms)
self.logger.debug(f"No input in timeout, calling scheduled {alarm_callback!r}")
alarm_callback()
self._did_something = True
self.logger.debug("Processing input")
for record in ready:
record.data()
self._did_something = True
@@ -0,0 +1,213 @@
# Urwid main loop code
# Copyright (C) 2004-2012 Ian Ward
# Copyright (C) 2008 Walter Mundt
# Copyright (C) 2009 Andrew Psaltis
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
"""Tornado IOLoop based urwid EventLoop implementation.
Tornado library is required.
"""
from __future__ import annotations
import asyncio
import functools
import logging
import typing
from contextlib import suppress
from tornado import ioloop
from .abstract_loop import EventLoop, ExitMainLoop
if typing.TYPE_CHECKING:
from collections.abc import Callable
from concurrent.futures import Executor
from typing_extensions import Literal, ParamSpec
_Spec = ParamSpec("_Spec")
_T = typing.TypeVar("_T")
__all__ = ("TornadoEventLoop",)
class TornadoEventLoop(EventLoop):
"""This is an Urwid-specific event loop to plug into its MainLoop.
It acts as an adaptor for Tornado's IOLoop which does all
heavy lifting except idle-callbacks.
"""
def __init__(self, loop: ioloop.IOLoop | None = None) -> None:
super().__init__()
self.logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
if loop:
self._loop: ioloop.IOLoop = loop
else:
try:
asyncio.get_running_loop()
except RuntimeError:
asyncio.set_event_loop(asyncio.new_event_loop())
self._loop = ioloop.IOLoop.current()
self._pending_alarms: dict[object, int] = {}
self._watch_handles: dict[int, int] = {} # {<watch_handle> : <file_descriptor>}
self._max_watch_handle: int = 0
self._exc: BaseException | None = None
self._idle_asyncio_handle: object | None = None
self._idle_handle: int = 0
self._idle_callbacks: dict[int, Callable[[], typing.Any]] = {}
def _also_call_idle(self, callback: Callable[_Spec, _T]) -> Callable[_Spec, _T]:
"""
Wrap the callback to also call _entering_idle.
"""
@functools.wraps(callback)
def wrapper(*args: _Spec.args, **kwargs: _Spec.kwargs) -> _T:
if not self._idle_asyncio_handle:
self._idle_asyncio_handle = self._loop.call_later(0, self._entering_idle)
return callback(*args, **kwargs)
return wrapper
def _entering_idle(self) -> None:
"""
Call all the registered idle callbacks.
"""
try:
for callback in self._idle_callbacks.values():
callback()
finally:
self._idle_asyncio_handle = None
def run_in_executor(
self,
executor: Executor,
func: Callable[_Spec, _T],
*args: _Spec.args,
**kwargs: _Spec.kwargs,
) -> asyncio.Future[_T]:
"""Run callable in executor.
:param executor: Executor to use for running the function
:type executor: concurrent.futures.Executor
:param func: function to call
:type func: Callable
:param args: arguments to function (positional only)
:type args: object
:param kwargs: keyword arguments to function (keyword only)
:type kwargs: object
:return: future object for the function call outcome.
:rtype: asyncio.Future
"""
return self._loop.run_in_executor(executor, functools.partial(func, *args, **kwargs))
def alarm(self, seconds: float, callback: Callable[[], typing.Any]):
@self._also_call_idle
@functools.wraps(callback)
def wrapped() -> None:
with suppress(KeyError):
del self._pending_alarms[handle]
self.handle_exit(callback)()
handle = self._loop.add_timeout(self._loop.time() + seconds, wrapped)
self._pending_alarms[handle] = 1
return handle
def remove_alarm(self, handle: object) -> bool:
self._loop.remove_timeout(handle)
try:
del self._pending_alarms[handle]
except KeyError:
return False
return True
def watch_file(self, fd: int, callback: Callable[[], _T]) -> int:
@self._also_call_idle
def handler(_fd: int, _events: int) -> None:
self.handle_exit(callback)()
self._loop.add_handler(fd, handler, ioloop.IOLoop.READ)
self._max_watch_handle += 1
handle = self._max_watch_handle
self._watch_handles[handle] = fd
return handle
def remove_watch_file(self, handle: int) -> bool:
if (fd := self._watch_handles.pop(handle, None)) is not None:
self._loop.remove_handler(fd)
return True
return False
def enter_idle(self, callback: Callable[[], typing.Any]) -> int:
"""
Add a callback for entering idle.
Returns a handle that may be passed to remove_idle()
"""
# XXX there's no such thing as "idle" in most event loops; this fakes
# it by adding extra callback to the timer and file watch callbacks.
self._idle_handle += 1
self._idle_callbacks[self._idle_handle] = callback
return self._idle_handle
def remove_enter_idle(self, handle: int) -> bool:
"""
Remove an idle callback.
Returns True if the handle was removed.
"""
try:
del self._idle_callbacks[handle]
except KeyError:
return False
return True
def handle_exit(self, f: Callable[_Spec, _T]) -> Callable[_Spec, _T | Literal[False]]:
@functools.wraps(f)
def wrapper(*args: _Spec.args, **kwargs: _Spec.kwargs) -> _T | Literal[False]:
try:
return f(*args, **kwargs)
except ExitMainLoop:
pass # handled later
except Exception as exc:
self._exc = exc
if self._idle_asyncio_handle:
# clean it up to prevent old callbacks
# from messing things up if loop is restarted
self._loop.remove_timeout(self._idle_asyncio_handle)
self._idle_asyncio_handle = None
self._loop.stop()
return False
return wrapper
def run(self) -> None:
self._loop.start()
if self._exc:
exc, self._exc = self._exc, None
raise exc.with_traceback(exc.__traceback__)
@@ -0,0 +1,309 @@
# Urwid main loop code using Python-3.5 features (Trio, Curio, etc)
# Copyright (C) 2018 Toshio Kuratomi
# Copyright (C) 2019 Tamas Nepusz
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
"""Trio Runner based urwid EventLoop implementation.
Trio library is required.
"""
from __future__ import annotations
import logging
import sys
import typing
import trio
from .abstract_loop import EventLoop, ExitMainLoop
if sys.version_info < (3, 11):
from exceptiongroup import BaseExceptionGroup # pylint: disable=redefined-builtin # backport
if typing.TYPE_CHECKING:
import io
from collections.abc import Awaitable, Callable, Hashable, Mapping
from typing_extensions import Concatenate, ParamSpec
_Spec = ParamSpec("_Spec")
__all__ = ("TrioEventLoop",)
class _TrioIdleCallbackInstrument(trio.abc.Instrument):
"""IDLE callbacks emulation helper."""
__slots__ = ("idle_callbacks",)
def __init__(self, idle_callbacks: Mapping[Hashable, Callable[[], typing.Any]]):
self.idle_callbacks = idle_callbacks
def before_io_wait(self, timeout: float) -> None:
if timeout > 0:
for idle_callback in self.idle_callbacks.values():
idle_callback()
class TrioEventLoop(EventLoop):
"""
Event loop based on the ``trio`` module.
``trio`` is an async library for Python 3.5 and later.
"""
def __init__(self) -> None:
"""Constructor."""
super().__init__()
self.logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
self._idle_handle = 0
self._idle_callbacks: dict[int, Callable[[], typing.Any]] = {}
self._pending_tasks: list[tuple[Callable[_Spec, Awaitable], trio.CancelScope, _Spec.args]] = []
self._nursery: trio.Nursery | None = None
self._sleep = trio.sleep
self._wait_readable = trio.lowlevel.wait_readable
def alarm(
self,
seconds: float,
callback: Callable[[], typing.Any],
) -> trio.CancelScope:
"""Calls `callback()` a given time from now.
:param seconds: time in seconds to wait before calling the callback
:type seconds: float
:param callback: function to call from the event loop
:type callback: Callable[[], typing.Any]
:return: a handle that may be passed to `remove_alarm()`
:rtype: trio.CancelScope
No parameters are passed to the callback.
"""
return self._start_task(self._alarm_task, seconds, callback)
def enter_idle(self, callback: Callable[[], typing.Any]) -> int:
"""Calls `callback()` when the event loop enters the idle state.
There is no such thing as being idle in a Trio event loop so we
simulate it by repeatedly calling `callback()` with a short delay.
"""
self._idle_handle += 1
self._idle_callbacks[self._idle_handle] = callback
return self._idle_handle
def remove_alarm(self, handle: trio.CancelScope) -> bool:
"""Removes an alarm.
Parameters:
handle: the handle of the alarm to remove
"""
return self._cancel_scope(handle)
def remove_enter_idle(self, handle: int) -> bool:
"""Removes an idle callback.
Parameters:
handle: the handle of the idle callback to remove
"""
try:
del self._idle_callbacks[handle]
except KeyError:
return False
return True
def remove_watch_file(self, handle: trio.CancelScope) -> bool:
"""Removes a file descriptor being watched for input.
Parameters:
handle: the handle of the file descriptor callback to remove
Returns:
True if the file descriptor was watched, False otherwise
"""
return self._cancel_scope(handle)
def _cancel_scope(self, scope: trio.CancelScope) -> bool:
"""Cancels the given Trio cancellation scope.
Returns:
True if the scope was cancelled, False if it was cancelled already
before invoking this function
"""
existed = not scope.cancel_called
scope.cancel()
return existed
def run(self) -> None:
"""Starts the event loop. Exits the loop when any callback raises an
exception. If ExitMainLoop is raised, exits cleanly.
"""
emulate_idle_callbacks = _TrioIdleCallbackInstrument(self._idle_callbacks)
try:
trio.run(self._main_task, instruments=[emulate_idle_callbacks])
except BaseException as exc:
self._handle_main_loop_exception(exc)
async def run_async(self) -> None:
"""Starts the main loop and blocks asynchronously until the main loop exits.
This allows one to embed an urwid app in a Trio app even if the Trio event loop is already running.
Example::
with trio.open_nursery() as nursery:
event_loop = urwid.TrioEventLoop()
# [...launch other async tasks in the nursery...]
loop = urwid.MainLoop(widget, event_loop=event_loop)
with loop.start():
await event_loop.run_async()
nursery.cancel_scope.cancel()
"""
emulate_idle_callbacks = _TrioIdleCallbackInstrument(self._idle_callbacks)
try:
trio.lowlevel.add_instrument(emulate_idle_callbacks)
try:
await self._main_task()
finally:
trio.lowlevel.remove_instrument(emulate_idle_callbacks)
except BaseException as exc:
self._handle_main_loop_exception(exc)
def watch_file(
self,
fd: int | io.IOBase,
callback: Callable[[], typing.Any],
) -> trio.CancelScope:
"""Calls `callback()` when the given file descriptor has some data
to read. No parameters are passed to the callback.
Parameters:
fd: file descriptor to watch for input
callback: function to call when some input is available
Returns:
a handle that may be passed to `remove_watch_file()`
"""
return self._start_task(self._watch_task, fd, callback)
async def _alarm_task(
self,
scope: trio.CancelScope,
seconds: float,
callback: Callable[[], typing.Any],
) -> None:
"""Asynchronous task that sleeps for a given number of seconds and then
calls the given callback.
Parameters:
scope: the cancellation scope that can be used to cancel the task
seconds: the number of seconds to wait
callback: the callback to call
"""
with scope:
await self._sleep(seconds)
callback()
def _handle_main_loop_exception(self, exc: BaseException) -> None:
"""Handles exceptions raised from the main loop, catching ExitMainLoop
instead of letting it propagate through.
Note that since Trio may collect multiple exceptions from tasks into an ExceptionGroup,
we cannot simply use a try..catch clause, we need a helper function like this.
"""
self._idle_callbacks.clear()
if isinstance(exc, BaseExceptionGroup) and len(exc.exceptions) == 1:
exc = exc.exceptions[0]
if isinstance(exc, ExitMainLoop):
return
raise exc.with_traceback(exc.__traceback__) from None
async def _main_task(self) -> None:
"""Main Trio task that opens a nursery and then sleeps until the user
exits the app by raising ExitMainLoop.
"""
try:
async with trio.open_nursery() as self._nursery:
self._schedule_pending_tasks()
await trio.sleep_forever()
finally:
self._nursery = None
def _schedule_pending_tasks(self) -> None:
"""Schedules all pending asynchronous tasks that were created before
the nursery to be executed on the nursery soon.
"""
for task, scope, args in self._pending_tasks:
self._nursery.start_soon(task, scope, *args)
self._pending_tasks.clear()
def _start_task(
self,
task: Callable[Concatenate[trio.CancelScope, _Spec], Awaitable],
*args: _Spec.args,
) -> trio.CancelScope:
"""Starts an asynchronous task in the Trio nursery managed by the
main loop. If the nursery has not started yet, store a reference to
the task and the arguments so we can start the task when the nursery
is open.
Parameters:
task: a Trio task to run
Returns:
a cancellation scope for the Trio task
"""
scope = trio.CancelScope()
if self._nursery:
self._nursery.start_soon(task, scope, *args)
else:
self._pending_tasks.append((task, scope, args))
return scope
async def _watch_task(
self,
scope: trio.CancelScope,
fd: int | io.IOBase,
callback: Callable[[], typing.Any],
) -> None:
"""Asynchronous task that watches the given file descriptor and calls
the given callback whenever the file descriptor becomes readable.
Parameters:
scope: the cancellation scope that can be used to cancel the task
fd: the file descriptor to watch
callback: the callback to call
"""
with scope:
# We check for the scope being cancelled before calling
# wait_readable because if callback cancels the scope, fd might be
# closed and calling wait_readable with a closed fd does not work.
while not scope.cancel_called:
await self._wait_readable(fd)
callback()
@@ -0,0 +1,270 @@
# Urwid main loop code
# Copyright (C) 2004-2012 Ian Ward
# Copyright (C) 2008 Walter Mundt
# Copyright (C) 2009 Andrew Psaltis
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
"""Twisted Reactor based urwid EventLoop implementation.
Twisted library is required.
"""
from __future__ import annotations
import functools
import logging
import sys
import typing
from twisted.internet.abstract import FileDescriptor
from twisted.internet.error import AlreadyCalled, AlreadyCancelled
from .abstract_loop import EventLoop, ExitMainLoop
if typing.TYPE_CHECKING:
from collections.abc import Callable
from concurrent.futures import Executor, Future
from twisted.internet.base import DelayedCall, ReactorBase
from typing_extensions import ParamSpec
_Spec = ParamSpec("_Spec")
_T = typing.TypeVar("_T")
__all__ = ("TwistedEventLoop",)
class _TwistedInputDescriptor(FileDescriptor):
def __init__(self, reactor: ReactorBase, fd: int, cb: Callable[[], typing.Any]) -> None:
self._fileno = fd
self.cb = cb
super().__init__(reactor) # ReactorBase implement full API as required in interfaces
def fileno(self) -> int:
return self._fileno
def doRead(self):
return self.cb()
def getHost(self):
raise NotImplementedError("No network operation expected")
def getPeer(self):
raise NotImplementedError("No network operation expected")
def writeSomeData(self, data: bytes) -> None:
raise NotImplementedError("Reduced functionality: read-only")
class TwistedEventLoop(EventLoop):
"""
Event loop based on Twisted_
"""
_idle_emulation_delay = 1.0 / 256 # a short time (in seconds)
def __init__(self, reactor: ReactorBase | None = None, manage_reactor: bool = True) -> None:
"""
:param reactor: reactor to use
:type reactor: :class:`twisted.internet.reactor`.
:param: manage_reactor: `True` if you want this event loop to run
and stop the reactor.
:type manage_reactor: boolean
.. WARNING::
Twisted's reactor doesn't like to be stopped and run again. If you
need to stop and run your :class:`MainLoop`, consider setting
``manage_reactor=False`` and take care of running/stopping the reactor
at the beginning/ending of your program yourself.
You can also forego using :class:`MainLoop`'s run() entirely, and
instead call start() and stop() before and after starting the
reactor.
.. _Twisted: https://twisted.org/
"""
super().__init__()
self.logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
if reactor is None:
import twisted.internet.reactor
reactor = twisted.internet.reactor
self.reactor: ReactorBase = reactor
self._watch_files: dict[int, _TwistedInputDescriptor] = {}
self._idle_handle: int = 0
self._twisted_idle_enabled = False
self._idle_callbacks: dict[int, Callable[[], typing.Any]] = {}
self._exc: BaseException | None = None
self.manage_reactor = manage_reactor
self._enable_twisted_idle()
def run_in_executor(
self,
executor: Executor,
func: Callable[..., _T],
*args: object,
**kwargs: object,
) -> Future[_T]:
raise NotImplementedError(
"Twisted implement it's own ThreadPool executor. Please use native API for call:\n"
"'threads.deferToThread(Callable[..., Any], *args, **kwargs)'\n"
"And use 'addCallback' api for callbacks:\n"
"'threads.deferToThread(Callable[..., T], *args, **kwargs).addCallback(Callable[[T], None])'"
)
def alarm(self, seconds: float, callback: Callable[[], typing.Any]) -> DelayedCall:
"""
Call callback() a given time from now. No parameters are
passed to callback.
Returns a handle that may be passed to remove_alarm()
seconds -- floating point time to wait before calling callback
callback -- function to call from event loop
"""
handle = self.reactor.callLater(seconds, self.handle_exit(callback))
return handle
def remove_alarm(self, handle: DelayedCall) -> bool:
"""
Remove an alarm.
Returns True if the alarm exists, False otherwise
"""
try:
handle.cancel()
except (AlreadyCancelled, AlreadyCalled):
return False
return True
def watch_file(self, fd: int, callback: Callable[[], typing.Any]) -> int:
"""
Call callback() when fd has some data to read. No parameters
are passed to callback.
Returns a handle that may be passed to remove_watch_file()
fd -- file descriptor to watch for input
callback -- function to call when input is available
"""
ind = _TwistedInputDescriptor(self.reactor, fd, self.handle_exit(callback))
self._watch_files[fd] = ind
self.reactor.addReader(ind)
return fd
def remove_watch_file(self, handle: int) -> bool:
"""
Remove an input file.
Returns True if the input file exists, False otherwise
"""
if handle in self._watch_files:
self.reactor.removeReader(self._watch_files[handle])
del self._watch_files[handle]
return True
return False
def enter_idle(self, callback: Callable[[], typing.Any]) -> int:
"""
Add a callback for entering idle.
Returns a handle that may be passed to remove_enter_idle()
"""
self._idle_handle += 1
self._idle_callbacks[self._idle_handle] = callback
return self._idle_handle
def _enable_twisted_idle(self) -> None:
"""
Twisted's reactors don't have an idle or enter-idle callback
so the best we can do for now is to set a timer event in a very
short time to approximate an enter-idle callback.
.. WARNING::
This will perform worse than the other event loops until we can find a
fix or workaround
"""
if self._twisted_idle_enabled:
return
self.reactor.callLater(
self._idle_emulation_delay,
self.handle_exit(self._twisted_idle_callback, enable_idle=False),
)
self._twisted_idle_enabled = True
def _twisted_idle_callback(self) -> None:
for callback in self._idle_callbacks.values():
callback()
self._twisted_idle_enabled = False
def remove_enter_idle(self, handle: int) -> bool:
"""
Remove an idle callback.
Returns True if the handle was removed.
"""
try:
del self._idle_callbacks[handle]
except KeyError:
return False
return True
def run(self) -> None:
"""
Start the event loop. Exit the loop when any callback raises
an exception. If ExitMainLoop is raised, exit cleanly.
"""
if not self.manage_reactor:
return
self.reactor.run()
if self._exc:
# An exception caused us to exit, raise it now
exc = self._exc
self._exc = None
raise exc.with_traceback(exc.__traceback__)
def handle_exit(self, f: Callable[_Spec, _T], enable_idle: bool = True) -> Callable[_Spec, _T | None]:
"""
Decorator that cleanly exits the :class:`TwistedEventLoop` if
:class:`ExitMainLoop` is thrown inside of the wrapped function. Store the
exception info if some other exception occurs, it will be reraised after
the loop quits.
*f* -- function to be wrapped
"""
@functools.wraps(f)
def wrapper(*args: _Spec.args, **kwargs: _Spec.kwargs) -> _T | None:
rval = None
try:
rval = f(*args, **kwargs)
except ExitMainLoop:
if self.manage_reactor:
self.reactor.stop()
except BaseException as exc:
print(sys.exc_info())
self._exc = exc
if self.manage_reactor:
self.reactor.crash()
if enable_idle:
self._enable_twisted_idle()
return rval
return wrapper
@@ -0,0 +1,277 @@
# Urwid main loop code using ZeroMQ queues
# Copyright (C) 2019 Dave Jones
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
"""ZeroMQ based urwid EventLoop implementation.
`ZeroMQ <https://zeromq.org>`_ library is required.
"""
from __future__ import annotations
import contextlib
import errno
import heapq
import logging
import os
import time
import typing
from itertools import count
import zmq
from .abstract_loop import EventLoop, ExitMainLoop
if typing.TYPE_CHECKING:
import io
from collections.abc import Callable
from concurrent.futures import Executor, Future
from typing_extensions import ParamSpec
ZMQAlarmHandle = typing.TypeVar("ZMQAlarmHandle")
_T = typing.TypeVar("_T")
_Spec = ParamSpec("_Spec")
class ZMQEventLoop(EventLoop):
"""
This class is an urwid event loop for `ZeroMQ`_ applications. It is very
similar to :class:`SelectEventLoop`, supporting the usual :meth:`alarm`
events and file watching (:meth:`watch_file`) capabilities, but also
incorporates the ability to watch zmq queues for events
(:meth:`watch_queue`).
.. _ZeroMQ: https://zeromq.org/
"""
_alarm_break = count()
def __init__(self) -> None:
super().__init__()
self.logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
self._did_something = True
self._alarms: list[tuple[float, int, Callable[[], typing.Any]]] = []
self._poller = zmq.Poller()
self._queue_callbacks: dict[int, Callable[[], typing.Any]] = {}
self._idle_handle = 0
self._idle_callbacks: dict[int, Callable[[], typing.Any]] = {}
def run_in_executor(
self,
executor: Executor,
func: Callable[_Spec, _T],
*args: _Spec.args,
**kwargs: _Spec.kwargs,
) -> Future[_T]:
"""Run callable in executor.
:param executor: Executor to use for running the function
:type executor: concurrent.futures.Executor
:param func: function to call
:type func: Callable
:param args: positional arguments to function
:type args: object
:param kwargs: keyword arguments to function
:type kwargs: object
:return: future object for the function call outcome.
:rtype: concurrent.futures.Future
"""
return executor.submit(func, *args, **kwargs)
def alarm(self, seconds: float, callback: Callable[[], typing.Any]) -> ZMQAlarmHandle:
"""
Call *callback* a given time from now. No parameters are passed to
callback. Returns a handle that may be passed to :meth:`remove_alarm`.
:param float seconds:
floating point time to wait before calling callback.
:param callback:
function to call from event loop.
"""
handle = (time.time() + seconds, next(self._alarm_break), callback)
heapq.heappush(self._alarms, handle)
return handle
def remove_alarm(self, handle: ZMQAlarmHandle) -> bool:
"""
Remove an alarm. Returns ``True`` if the alarm exists, ``False``
otherwise.
"""
try:
self._alarms.remove(handle)
heapq.heapify(self._alarms)
except ValueError:
return False
return True
def watch_queue(
self,
queue: zmq.Socket,
callback: Callable[[], typing.Any],
flags: int = zmq.POLLIN,
) -> zmq.Socket:
"""
Call *callback* when zmq *queue* has something to read (when *flags* is
set to ``POLLIN``, the default) or is available to write (when *flags*
is set to ``POLLOUT``). No parameters are passed to the callback.
Returns a handle that may be passed to :meth:`remove_watch_queue`.
:param queue:
The zmq queue to poll.
:param callback:
The function to call when the poll is successful.
:param int flags:
The condition to monitor on the queue (defaults to ``POLLIN``).
"""
if queue in self._queue_callbacks:
raise ValueError(f"already watching {queue!r}")
self._poller.register(queue, flags)
self._queue_callbacks[queue] = callback
return queue
def watch_file(
self,
fd: int | io.TextIOWrapper,
callback: Callable[[], typing.Any],
flags: int = zmq.POLLIN,
) -> io.TextIOWrapper:
"""
Call *callback* when *fd* has some data to read. No parameters are
passed to the callback. The *flags* are as for :meth:`watch_queue`.
Returns a handle that may be passed to :meth:`remove_watch_file`.
:param fd:
The file-like object, or fileno to monitor.
:param callback:
The function to call when the file has data available.
:param int flags:
The condition to monitor on the file (defaults to ``POLLIN``).
"""
if isinstance(fd, int):
fd = os.fdopen(fd)
self._poller.register(fd, flags)
self._queue_callbacks[fd.fileno()] = callback
return fd
def remove_watch_queue(self, handle: zmq.Socket) -> bool:
"""
Remove a queue from background polling. Returns ``True`` if the queue
was being monitored, ``False`` otherwise.
"""
try:
try:
self._poller.unregister(handle)
finally:
self._queue_callbacks.pop(handle, None)
except KeyError:
return False
return True
def remove_watch_file(self, handle: io.TextIOWrapper) -> bool:
"""
Remove a file from background polling. Returns ``True`` if the file was
being monitored, ``False`` otherwise.
"""
try:
try:
self._poller.unregister(handle)
finally:
self._queue_callbacks.pop(handle.fileno(), None)
except KeyError:
return False
return True
def enter_idle(self, callback: Callable[[], typing.Any]) -> int:
"""
Add a *callback* to be executed when the event loop detects it is idle.
Returns a handle that may be passed to :meth:`remove_enter_idle`.
"""
self._idle_handle += 1
self._idle_callbacks[self._idle_handle] = callback
return self._idle_handle
def remove_enter_idle(self, handle: int) -> bool:
"""
Remove an idle callback. Returns ``True`` if *handle* was removed,
``False`` otherwise.
"""
try:
del self._idle_callbacks[handle]
except KeyError:
return False
return True
def _entering_idle(self) -> None:
for callback in list(self._idle_callbacks.values()):
callback()
def run(self) -> None:
"""
Start the event loop. Exit the loop when any callback raises an
exception. If :exc:`ExitMainLoop` is raised, exit cleanly.
"""
with contextlib.suppress(ExitMainLoop):
while True:
try:
self._loop()
except zmq.error.ZMQError as exc: # noqa: PERF203
if exc.errno != errno.EINTR:
raise
def _loop(self) -> None:
"""
A single iteration of the event loop.
"""
state = "wait" # default state not expecting any action
if self._alarms or self._did_something:
timeout = 0
if self._alarms:
state = "alarm"
timeout = max(0.0, self._alarms[0][0] - time.time())
if self._did_something and (not self._alarms or (self._alarms and timeout > 0)):
state = "idle"
timeout = 0
ready = dict(self._poller.poll(timeout * 1000))
else:
ready = dict(self._poller.poll())
if not ready:
if state == "idle":
self._entering_idle()
self._did_something = False
elif state == "alarm":
_due, _tie_break, callback = heapq.heappop(self._alarms)
callback()
self._did_something = True
for queue in ready:
self._queue_callbacks[queue]()
self._did_something = True
@@ -0,0 +1,690 @@
# Urwid BigText fonts
# Copyright (C) 2004-2006 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
import typing
import warnings
from pprint import pformat
from urwid.canvas import CanvasError, TextCanvas
from urwid.display.escape import SAFE_ASCII_DEC_SPECIAL_RE
from urwid.util import apply_target_encoding, str_util
if typing.TYPE_CHECKING:
from collections.abc import Iterator, Sequence
from typing_extensions import Literal
def separate_glyphs(gdata: str, height: int) -> tuple[dict[str, tuple[int, list[str]]], bool]:
"""return (dictionary of glyphs, utf8 required)"""
gl: list[str] = gdata.split("\n")[1:-1]
if any("\t" in elem for elem in gl):
raise ValueError(f"Incorrect glyphs data:\n{gdata!r}")
if len(gl) != height + 1:
raise ValueError(f"Incorrect glyphs height (expected: {height}):\n{gdata}")
key_line: str = gl[0]
character: str | None = None # current character
key_index = 0 # index into character key line
end_col = 0 # column position at end of glyph
start_col = 0 # column position at start of glyph
jl: list[int] = [0] * height # indexes into lines of gdata (gl)
result: dict[str, tuple[int, list[str]]] = {}
utf8_required = False
while True:
if character is None:
if key_index >= len(key_line):
break
character = key_line[key_index]
if key_index < len(key_line) and key_line[key_index] == character:
end_col += str_util.get_char_width(character)
key_index += 1
continue
out: list[str] = []
y = 0
fill = 0
for k, line in enumerate(gl[1:]):
j: int = jl[k]
y = 0
fill = 0
while y < end_col - start_col:
if j >= len(line):
fill = end_col - start_col - y
break
y += str_util.get_char_width(line[j])
j += 1
if y + fill != end_col - start_col:
raise ValueError(repr((y, fill, end_col)))
segment = line[jl[k] : j]
if not SAFE_ASCII_DEC_SPECIAL_RE.match(segment):
utf8_required = True
out.append(segment + " " * fill)
jl[k] = j
start_col = end_col
result[character] = (y + fill, out)
character = None
return result, utf8_required
def add_font(name: str, cls: FontRegistry) -> None:
warnings.warn(
"`add_font` is deprecated, please set 'name' attribute to the font class,"
" use metaclass keyword argument 'font_name'"
" or use `Font.register(<name>)`",
DeprecationWarning,
stacklevel=2,
)
cls.register(name)
class FontRegistryWarning(UserWarning):
"""FontRegistry warning."""
class FontRegistry(type):
"""Font registry.
Store all registered fonts, register during class creation if possible.
"""
__slots__ = ()
__registered: typing.ClassVar[dict[str, FontRegistry]] = {}
def __iter__(cls) -> Iterator[str]:
"""Iterate over registered font names."""
return iter(cls.__registered)
def __getitem__(cls, item: str) -> FontRegistry | None:
"""Get font by name if registered."""
return cls.__registered.get(item)
def __class_getitem__(mcs, item: str) -> FontRegistry | None:
"""Get font by name if registered.
This method is needed to get access to font from registry class.
>>> from urwid.util import set_temporary_encoding
>>> repr(FontRegistry["a"])
'None'
>>> font = FontRegistry["Thin 3x3"]()
>>> font.height
3
>>> with set_temporary_encoding("utf-8"):
... canvas: TextCanvas = font.render("+")
>>> b'\\n'.join(canvas.text).decode('utf-8')
' \\n ┼\\n '
"""
return mcs.__registered.get(item)
@property
def registered(cls) -> Sequence[str]:
"""Registered font names in alphabetical order."""
return tuple(sorted(cls.__registered))
@classmethod
def as_list(mcs) -> list[tuple[str, FontRegistry]]:
"""List of (font name, font class) tuples."""
return list(mcs.__registered.items())
def __new__(
mcs: type[FontRegistry],
name: str,
bases: tuple[type, ...],
namespace: dict[str, typing.Any],
**kwds: typing.Any,
) -> FontRegistry:
font_name: str = namespace.setdefault("name", kwds.get("font_name", ""))
font_class = super().__new__(mcs, name, bases, namespace)
if font_name:
if font_name not in mcs.__registered:
mcs.__registered[font_name] = font_class
if mcs.__registered[font_name] != font_class:
warnings.warn(
f"{font_name!r} is already registered, please override explicit if required or change name",
FontRegistryWarning,
stacklevel=2,
)
return font_class
def register(cls, font_name: str) -> None:
"""Register font explicit.
:param font_name: Font name to use in registration.
"""
if not font_name:
raise ValueError('"font_name" is not set.')
cls.__registered[font_name] = cls
get_all_fonts = FontRegistry.as_list
class Font(metaclass=FontRegistry):
"""Font base class."""
__slots__ = ("canvas", "char", "utf8_required")
height: int # pylint: disable=declare-non-slot
data: Sequence[str] # pylint: disable=declare-non-slot
name: str # pylint: disable=declare-non-slot
def __init__(self) -> None:
if not self.height:
raise ValueError(f'"height" is invalid: {self.height!r}')
if not self.data:
raise ValueError(f'"data" is empty: {self.data!r}')
self.char: dict[str, tuple[int, list[str]]] = {}
self.canvas: dict[str, TextCanvas] = {}
self.utf8_required = False
if isinstance(self.data, str):
self.add_glyphs(self.data)
else:
for gdata in self.data:
self.add_glyphs(gdata)
def __repr__(self) -> str:
return f"{self.__class__.__name__}()"
def __str__(self) -> str:
"""Font description."""
return f"{self.__class__.__name__}():\n {self.height!r}\n {pformat(self.data, indent=4)}"
@staticmethod
def _to_text(
obj: str,
encoding: str = "utf-8",
errors: Literal["strict", "ignore", "replace"] = "strict",
) -> str:
warnings.warn(
"_to_text is deprecated: only text fonts are supported. API will be removed in version 4.0.",
DeprecationWarning,
stacklevel=3,
)
if isinstance(obj, str):
return obj
raise TypeError(f"{obj!r} is not str")
def add_glyphs(self, gdata: str) -> None:
d, utf8_required = separate_glyphs(gdata, self.height)
self.char.update(d)
self.utf8_required |= utf8_required
def characters(self) -> str:
return "".join(sorted(self.char))
def char_width(self, character: str) -> int:
if character in self.char:
return self.char[character][0]
return 0
def char_data(self, character: str) -> list[str]:
return self.char[character][1]
def render(self, character: str) -> TextCanvas:
if character in self.canvas:
return self.canvas[character]
width, line = self.char[character]
byte_lines = []
character_set_lines = []
for d in line:
t, cs = apply_target_encoding(d)
byte_lines.append(t)
character_set_lines.append(cs)
try:
canv = TextCanvas(byte_lines, None, character_set_lines, maxcol=width, check_width=False)
except CanvasError as exc:
raise CanvasError(f"Failed render of {character!r} from line {line!r}:\n{self}\n:{exc}").with_traceback(
exc.__traceback__
) from exc
self.canvas[character] = canv
return canv
# safe_palette = u"┘┐┌└┼─├┤┴┬│"
# more_palette = u"═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡╢╣╤╥╦╧╨╩╪╫╬○"
# block_palette = u"▄#█#▀#▌#▐#▖#▗#▘#▙#▚#▛#▜#▝#▞#▟"
class Thin3x3Font(Font):
name = "Thin 3x3"
height = 3
data = (
"""
000111222333444555666777888999 !
┌─┐ ┐ ┌─┐┌─┐ ┐┌─ ┌─ ┌─┐┌─┐┌─┐ │
│ │ │ ┌─┘ ─┤└─┼└─┐├─┐ ┼├─┤└─┤ │
└─┘ ┴ └─ └─┘ ┴ ─┘└─┘ ┴└─┘ ─┘ .
""",
r"""
"###$$$%%%'*++,--.///:;==???[[\\\]]^__`
" ┼┼┌┼┐O /' /.. _┌─┐┌ \ ┐^ `
┼┼└┼┐ / * ┼ ─ / ., _ ┌┘│ \ │
└┼┘/ O , ./ . └ \ ┘ ──
""",
)
class Thin4x3Font(Font):
name = "Thin 4x3"
height = 3
data = (
*Thin3x3Font.data,
"""
0000111122223333444455556666777788889999 ####$$$$
┌──┐ ┐ ┌──┐┌──┐ ┐┌── ┌── ┌──┐┌──┐┌──┐ ┼─┼┌┼┼┐
│ │ │ ┌──┘ ─┤└──┼└──┐├──┐ ┼├──┤└──┤ ┼─┼└┼┼┐
└──┘ ┴ └── └──┘ ┴ ──┘└──┘ ┴└──┘ ──┘ └┼┼┘
""",
)
class Sextant3x3Font(Font):
name = "Sextant 3x3"
height = 3
data = (
"""
!!!###$$$%%%&&&'''((()))***+++,,,---...///
▐ 🬞🬲🬲🬞🬍🬋🬉🬄🬖🬦🬧 🬉 🬞🬅 🬁🬢 🬞🬦🬞 🬦 🬖
🬉 🬇🬛🬛🬞🬰🬗🬞🬅🬭🬦🬈🬖 🬉🬏 🬘 🬇🬨🬈🬁🬨🬂 🬭 🬁🬂🬂 🬭 🬞🬅
🬁 🬀🬀 🬁 🬂 🬂🬁 🬁 🬁 🬅 🬂
""",
"""
000111222333444555666777888999
🬦🬂🬧🬞🬫 🬇🬂🬧🬁🬂🬧 🬞🬫▐🬂🬂🬞🬅🬀🬁🬂🬙🬦🬂🬧🬦🬂🬧
▐🬁▐ ▐ 🬞🬅🬀 🬂🬧🬇🬌🬫🬁🬂🬧▐🬂🬧 🬔 🬦🬂🬧 🬂🬙
🬂🬀 🬁 🬁🬂🬂🬁🬂🬀 🬁🬁🬂🬀 🬂🬀 🬀 🬂🬀 🬂
""",
"""
\"\"\"
🬄🬄
""",
"""
:::;;;<<<===>>>???@@@
🬭 🬭 🬖🬀 🬁🬢 🬇🬂🬧🬦🬂🬧
🬰 🬰 🬁🬢 🬠🬰🬰 🬖🬀 🬇🬀▐🬉🬅
🬂 🬅 🬀 🬁 🬁 🬂🬀
""",
"""
AAABBBCCCDDDEEEFFFGGGHHHIIIJJJKKKLLLMMMNNNOOOPPPQQQ
🬞🬅🬢▐🬂🬧🬦🬂🬈▐🬂🬧▐🬂🬂▐🬂🬂🬦🬂🬈▐ ▐ 🬨🬀 ▐▐🬞🬅▐ ▐🬢🬫▐🬢▐🬦🬂🬧▐🬂🬧🬦🬂🬧
▐🬋🬫▐🬂🬧▐ 🬞▐ ▐▐🬂 ▐🬂 ▐ 🬨▐🬂🬨 ▐ 🬞 ▐▐🬈🬏▐ ▐🬁▐▐ 🬨▐ ▐▐🬂🬀▐🬇🬘
🬁 🬁🬁🬂🬀 🬂🬀🬁🬂🬀🬁🬂🬂🬁 🬂🬂🬁 🬁 🬂🬀 🬂🬀🬁 🬁🬁🬂🬂🬁 🬁🬁 🬁 🬂🬀🬁 🬂🬁
""",
"""
RRRSSSTTTUUUVVVWWWXXXYYYZZZ[[[]]]^^^___```
▐🬂🬧🬦🬂🬈🬁🬨🬂▐ ▐▐ ▐▐ ▐🬉🬏🬘▐ ▐🬁🬂🬙 🬕🬀 🬂▌🬞🬅🬢 🬈🬏
▐🬊🬐🬞🬂🬧 ▐ ▐ ▐🬉🬏🬘▐🬖🬷🬞🬅🬢 🬧🬀🬞🬅 ▌ ▌
🬁 🬁 🬂🬀 🬁 🬂🬀 🬁 🬁 🬁🬁 🬁 🬁 🬁🬂🬂 🬂🬀 🬂🬀 🬂🬂🬂
""",
"""
\\\\\\
🬇🬏
🬁🬢
""",
)
class Sextant2x2Font(Font):
name = "Sextant 2x2"
height = 2
data = """
..,,%%00112233445566778899
🬁🬖▐🬨🬇▌🬁🬗🬠🬸🬦▐▐🬒▐🬭🬁🬙▐🬸▐🬸
🬇 🬇🬀🬁🬇🬉🬍 🬄🬉🬋🬇🬍🬁🬊🬇🬅🬉🬍 🬄🬉🬍 🬉
"""
class HalfBlock5x4Font(Font):
name = "Half Block 5x4"
height = 4
data = (
"""
00000111112222233333444445555566666777778888899999 !!
▄▀▀▄ ▄█ ▄▀▀▄ ▄▀▀▄ ▄ █ █▀▀▀ ▄▀▀ ▀▀▀█ ▄▀▀▄ ▄▀▀▄ █
█ █ █ ▄▀ ▄▀ █▄▄█ █▄▄ █▄▄ ▐▌ ▀▄▄▀ ▀▄▄█ █
█ █ █ ▄▀ ▄ █ █ █ █ █ █ █ █ █ ▀
▀▀ ▀▀▀ ▀▀▀▀ ▀▀ ▀ ▀▀▀ ▀▀ ▀ ▀▀ ▀▀ ▀
""",
'''
"""######$$$$$$%%%%%&&&&&((()))******++++++,,,-----..////::;;;
█▐▌ █ █ ▄▀█▀▄ ▐▌▐▌ ▄▀▄ █ █ ▄ ▄ ▄ ▐▌
▀█▀█▀ ▀▄█▄ █ ▀▄▀ ▐▌ ▐▌ ▄▄█▄▄ ▄▄█▄▄ ▄▄▄▄ █ ▀ ▀
▀█▀█▀ ▄ █ █ ▐▌▄ █ ▀▄▌▐▌ ▐▌ ▄▀▄ █ ▐▌ ▀ ▄▀
▀ ▀ ▀▀▀ ▀ ▀ ▀▀ ▀ ▀ ▄▀ ▀ ▀
''',
r"""
<<<<<=====>>>>>?????@@@@@@[[[[\\\\]]]]^^^^____```{{{{||}}}}~~~~''```
▄▀ ▀▄ ▄▀▀▄ ▄▀▀▀▄ █▀▀ ▐▌ ▀▀█ ▄▀▄ ▀▄ ▄▀ █ ▀▄ ▄ █ ▄▀
▄▀ ▀▀▀▀ ▀▄ ▄▀ █ █▀█ █ █ █ ▄▀ █ ▀▄ ▐▐▌▌
▀▄ ▀▀▀▀ ▄▀ ▀ █ ▀▀▀ █ ▐▌ █ █ █ █ ▀
▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀▀▀ ▀▀▀▀ ▀ ▀ ▀
""",
"""
AAAAABBBBBCCCCCDDDDDEEEEEFFFFFGGGGGHHHHHIIJJJJJKKKKK
▄▀▀▄ █▀▀▄ ▄▀▀▄ █▀▀▄ █▀▀▀ █▀▀▀ ▄▀▀▄ █ █ █ █ █ █
█▄▄█ █▄▄▀ █ █ █ █▄▄ █▄▄ █ █▄▄█ █ █ █▄▀
█ █ █ █ █ ▄ █ █ █ █ █ ▀█ █ █ █ ▄ █ █ ▀▄
▀ ▀ ▀▀▀ ▀▀ ▀▀▀ ▀▀▀▀ ▀ ▀▀ ▀ ▀ ▀ ▀▀ ▀ ▀
""",
"""
LLLLLMMMMMMNNNNNOOOOOPPPPPQQQQQRRRRRSSSSSTTTTT
█ █▄ ▄█ ██ █ ▄▀▀▄ █▀▀▄ ▄▀▀▄ █▀▀▄ ▄▀▀▄ ▀▀█▀▀
█ █ ▀ █ █▐▌█ █ █ █▄▄▀ █ █ █▄▄▀ ▀▄▄ █
█ █ █ █ ██ █ █ █ █ ▌█ █ █ ▄ █ █
▀▀▀▀ ▀ ▀ ▀ ▀ ▀▀ ▀ ▀▀▌ ▀ ▀ ▀▀ ▀
""",
"""
UUUUUVVVVVVWWWWWWXXXXXXYYYYYYZZZZZ
█ █ █ █ █ █ █ █ █ █ ▀▀▀█
█ █ ▐▌ ▐▌ █ ▄ █ ▀▄▀ ▀▄▀ ▄▀
█ █ █ █ ▐▌█▐▌ ▄▀ ▀▄ █ █
▀▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀▀
""",
"""
aaaaabbbbbcccccdddddeeeeeffffggggghhhhhiijjjjkkkkk
█ █ ▄▀▀ █ ▄ ▄ █
▀▀▄ █▀▀▄ ▄▀▀▄ ▄▀▀█ ▄▀▀▄ ▀█▀ ▄▀▀▄ █▀▀▄ ▄ ▄ █ ▄▀
▄▀▀█ █ █ █ ▄ █ █ █▀▀ █ ▀▄▄█ █ █ █ █ █▀▄
▀▀▀ ▀▀▀ ▀▀ ▀▀▀ ▀▀ ▀ ▄▄▀ ▀ ▀ ▀ ▄▄▀ ▀ ▀
""",
"""
llmmmmmmnnnnnooooopppppqqqqqrrrrssssstttt
█ █
█ █▀▄▀▄ █▀▀▄ ▄▀▀▄ █▀▀▄ ▄▀▀█ █▀▀ ▄▀▀▀ ▀█▀
█ █ █ █ █ █ █ █ █ █ █ █ █ ▀▀▄ █
▀ ▀ ▀ ▀ ▀ ▀▀ █▀▀ ▀▀█ ▀ ▀▀▀ ▀
""",
"""
uuuuuvvvvvwwwwwwxxxxxxyyyyyzzzzz
█ █ █ █ █ ▄ █ ▀▄ ▄▀ █ █ ▀▀█▀
█ █ ▐▌▐▌ ▐▌█▐▌ ▄▀▄ ▀▄▄█ ▄▀
▀▀ ▀▀ ▀ ▀ ▀ ▀ ▄▄▀ ▀▀▀▀
""",
)
class HalfBlock6x5Font(Font):
name = "Half Block 6x5"
height = 5
data = """
000000111111222222333333444444555555666666777777888888999999 ..::////
▄▀▀▀▄ ▄█ ▄▀▀▀▄ ▄▀▀▀▄ ▄ █ █▀▀▀▀ ▄▀▀▀ ▀▀▀▀█ ▄▀▀▀▄ ▄▀▀▀▄ █
█ █ █ █ █ █ █ █ █ ▐▌ █ █ █ █ ▀ ▐▌
█ █ █ ▄▀ ▀▀▄ ▀▀▀█▀ ▀▀▀▀▄ █▀▀▀▄ █ ▄▀▀▀▄ ▀▀▀█ ▄ █
█ █ █ ▄▀ ▄ █ █ █ █ █ ▐▌ █ █ █ ▐▌
▀▀▀ ▀▀▀ ▀▀▀▀▀ ▀▀▀ ▀ ▀▀▀▀ ▀▀▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀
"""
class HalfBlockHeavy6x5Font(Font):
name = "Half Block Heavy 6x5"
height = 5
data = """
000000111111222222333333444444555555666666777777888888999999 ..::////
▄███▄ ▐█▌ ▄███▄ ▄███▄ █▌ █████ ▄███▄ █████ ▄███▄ ▄███▄ █▌
█▌ ▐█ ▀█▌ ▀ ▐█ ▀ ▐█ █▌ █▌ █▌ █▌ █▌ █▌ ▐█ █▌ ▐█ █▌ ▐█
█▌ ▐█ █▌ ▄█▀ ██▌ █████ ████▄ ████▄ ▐█ ▐███▌ ▀████ █▌
█▌ ▐█ █▌ ▄█▀ ▄ ▐█ █▌ ▐█ █▌ ▐█ █▌ █▌ ▐█ ▐█ █▌▐█
▀███▀ ███▌ █████ ▀███▀ █▌ ████▀ ▀███▀ ▐█ ▀███▀ ▀███▀ █▌ █▌
"""
class Thin6x6Font(Font):
name = "Thin 6x6"
height = 6
data = (
"""
000000111111222222333333444444555555666666777777888888999999''
┌───┐ ┐ ┌───┐ ┌───┐ ┐ ┌─── ┌─── ┌───┐ ┌───┐ ┌───┐ │
│ │ │ │ │ ┌ │ │ │ │ │ │ │ │
│ / │ │ ┌───┘ ─┤ └──┼─ └───┐ ├───┐ ┼ ├───┤ └───┤
│ │ │ │ │ │ │ │ │ │ │ │ │
└───┘ ┴ └─── └───┘ ┴ ───┘ └───┘ ┴ └───┘ ───┘
""",
r'''
!! """######$$$$$$%%%%%%&&&&&&((()))******++++++
│ ││ ┌ ┌ ┌─┼─┐ ┌┐ / ┌─┐ / \
│ ─┼─┼─ │ │ └┘ / │ │ │ │ \ / │
│ │ │ └─┼─┐ / ┌─\┘ │ │ ──X── ──┼──
│ ─┼─┼─ │ │ / ┌┐ │ \, │ │ / \ │
. ┘ ┘ └─┼─┘ / └┘ └───\ \ /
''',
r"""
,,-----..//////::;;<<<<=====>>>>??????@@@@@@
/ ┌───┐ ┌───┐
/ . . / ──── \ │ │┌──┤
──── / / \ ┌─┘ ││ │
/ . , \ ──── / │ │└──┘
, . / \ / . └───┘
""",
r"""
[[\\\\\\]]^^^____``{{||}}~~~~~~
┌ \ ┐ /\ \ ┌ │ ┐
│ \ │ │ │ │ ┌─┐
│ \ │ ┤ │ ├ └─┘
│ \ │ │ │ │
└ \ ┘ ──── └ │ ┘
""",
"""
AAAAAABBBBBBCCCCCCDDDDDDEEEEEEFFFFFFGGGGGGHHHHHHIIJJJJJJ
┌───┐ ┬───┐ ┌───┐ ┬───┐ ┬───┐ ┬───┐ ┌───┐ ┬ ┬ ┬ ┬
│ │ │ │ │ │ │ │ │ │ │ │ │ │
├───┤ ├───┤ │ │ │ ├── ├── │ ──┬ ├───┤ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │ ┬ │
┴ ┴ ┴───┘ └───┘ ┴───┘ ┴───┘ ┴ └───┘ ┴ ┴ ┴ └───┘
""",
"""
KKKKKKLLLLLLMMMMMMNNNNNNOOOOOOPPPPPPQQQQQQRRRRRRSSSSSS
┬ ┬ ┬ ┌─┬─┐ ┬─┐ ┬ ┌───┐ ┬───┐ ┌───┐ ┬───┐ ┌───┐
│ ┌─┘ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
├─┴┐ │ │ │ │ │ │ │ │ │ ├───┘ │ │ ├─┬─┘ └───┐
│ └┐ │ │ │ │ │ │ │ │ │ │ ┐│ │ └─┐ │
┴ ┴ ┴───┘ ┴ ┴ ┴ └─┴ └───┘ ┴ └──┼┘ ┴ ┴ └───┘
└
""",
"""
TTTTTTUUUUUUVVVVVVWWWWWWXXXXXXYYYYYYZZZZZZ
┌─┬─┐ ┬ ┬ ┬ ┬ ┬ ┬ ┬ ┬ ┬ ┬ ┌───┐
│ │ │ │ │ │ │ └┐ ┌┘ │ │ ┌─┘
│ │ │ │ │ │ │ │ ├─┤ └─┬─┘ ┌┘
│ │ │ └┐ ┌┘ │ │ │ ┌┘ └┐ │ ┌┘
┴ └───┘ └─┘ └─┴─┘ ┴ ┴ ┴ └───┘
""",
"""
aaaaaabbbbbbccccccddddddeeeeeefffgggggghhhhhhiijjj
┌─┐
│ │ │ │ . .
┌───┐ ├───┐ ┌───┐ ┌───┤ ┌───┐ ┼ ┌───┐ ├───┐ ┐ ┐
┌───┤ │ │ │ │ │ ├───┘ │ │ │ │ │ │ │
└───┴ └───┘ └───┘ └───┘ └───┘ ┴ └───┤ ┴ ┴ ┴ │
└───┘ ─┘
""",
"""
kkkkkkllmmmmmmnnnnnnooooooppppppqqqqqqrrrrrssssss
│ │
│ ┌─ │ ┬─┬─┐ ┬───┐ ┌───┐ ┌───┐ ┌───┐ ┬──┐ ┌───┐
├─┴┐ │ │ │ │ │ │ │ │ │ │ │ │ │ └───┐
┴ └─ └ ┴ ┴ ┴ ┴ └───┘ ├───┘ └───┤ ┴ └───┘
│ │
""",
"""
ttttuuuuuuvvvvvvwwwwwwxxxxxxyyyyyyzzzzzz
│
─┼─ ┬ ┬ ┬ ┬ ┬ ┬ ─┐ ┌─ ┬ ┬ ────┬
│ │ │ └┐ ┌┘ │ │ │ ├─┤ │ │ ┌───┘
└─ └───┴ └─┘ └─┴─┘ ─┘ └─ └───┤ ┴────
└───┘
""",
)
class HalfBlock7x7Font(Font):
name = "Half Block 7x7"
height = 7
data = (
"""
0000000111111122222223333333444444455555556666666777777788888889999999'''
▄███▄ ▐█▌ ▄███▄ ▄███▄ █▌ ▐█████▌ ▄███▄ ▐█████▌ ▄███▄ ▄███▄ ▐█
▐█ █▌ ▀█▌ ▐█ █▌▐█ █▌▐█ █▌ ▐█ ▐█ ▐█ ▐█ █▌▐█ █▌▐█
▐█ ▐ █▌ █▌ █▌ ▐██ ▐█████▌▐████▄ ▐████▄ █▌ █████ ▀████▌
▐█ ▌ █▌ █▌ ▄█▀ █▌ █▌ █▌▐█ █▌ ▐█ ▐█ █▌ █▌
▐█ █▌ █▌ ▄█▀ ▐█ █▌ █▌ █▌▐█ █▌ █▌ ▐█ █▌ █▌
▀███▀ ███▌ ▐█████▌ ▀███▀ █▌ ▐████▀ ▀███▀ ▐█ ▀███▀ ▀███▀
""",
'''
!!! """""#######$$$$$$$%%%%%%%&&&&&&&(((())))*******++++++
▐█ ▐█ █▌ ▐█ █▌ █ ▄ █▌ ▄█▄ █▌▐█ ▄▄ ▄▄
▐█ ▐█ █▌▐█████▌ ▄███▄ ▐█▌▐█ ▐█ █▌ ▐█ █▌ ▀█▄█▀ ▐█
▐█ ▐█ █▌ ▐█▄█▄▄ ▀ █▌ ███ █▌ ▐█ ▐█████▌ ████▌
▐█ ▐█████▌ ▀▀█▀█▌ ▐█ ▄ ███▌▄ █▌ ▐█ ▄█▀█▄ ▐█
▐█ █▌ ▀███▀ █▌▐█▌▐█ █▌ ▐█ █▌ ▀▀ ▀▀
▐█ █ ▐█ ▀ ▀██▀█▌ █▌▐█
''',
"""
,,,------.../////:::;;;<<<<<<<======>>>>>>>???????@@@@@@@
█▌ ▄█▌ ▐█▄ ▄███▄ ▄███▄
▐█ ▐█ ▐█ ▄█▀ ▐████▌ ▀█▄ ▐█ █▌▐█ ▄▄█▌
▐████▌ █▌ ▐██ ██▌ █▌ ▐█▐█▀█▌
▐█ ▐█ ▐█ ▀█▄ ▐████▌ ▄█▀ █▌ ▐█▐█▄█▌
█▌ ▀ ▀█▌ ▐█▀ ▐█ ▀▀▀
▐█ ▐█ ▐█ █▌ ▀███▀
▀
""",
r"""
[[[[\\\\\]]]]^^^^^^^_____```{{{{{|||}}}}}~~~~~~~```
▐██▌▐█ ▐██▌ ▐█▌ ▐█ █▌▐█ ▐█ █▌
▐█ █▌ █▌ ▐█ █▌ █▌ █▌ ▐█ ▐█ ▄▄ ▐█
▐█ ▐█ █▌▐█ █▌ ▄█▌ ▐█ ▐█▄ ▐▀▀█▄▄▌
▐█ █▌ █▌ ▀█▌ ▐█ ▐█▀ ▀▀
▐█ ▐█ █▌ █▌ ▐█ ▐█
▐██▌ █▌▐██▌ █████ █▌▐█ ▐█
""",
"""
AAAAAAABBBBBBBCCCCCCCDDDDDDDEEEEEEEFFFFFFFGGGGGGGHHHHHHHIIIIJJJJJJJ
▄███▄ ▐████▄ ▄███▄ ▐████▄ ▐█████▌▐█████▌ ▄███▄ ▐█ █▌ ██▌ █▌
▐█ █▌▐█ █▌▐█ ▐█ █▌▐█ ▐█ ▐█ ▐█ █▌ ▐█ █▌
▐█████▌▐█████ ▐█ ▐█ █▌▐████ ▐████ ▐█ ▐█████▌ ▐█ █▌
▐█ █▌▐█ █▌▐█ ▐█ █▌▐█ ▐█ ▐█ ██▌▐█ █▌ ▐█ █▌
▐█ █▌▐█ █▌▐█ ▐█ █▌▐█ ▐█ ▐█ █▌▐█ █▌ ▐█ ▐█ █▌
▐█ █▌▐████▀ ▀███▀ ▐████▀ ▐█████▌▐█ ▀███▀ ▐█ █▌ ██▌ ▀███▀
""",
"""
KKKKKKKLLLLLLLMMMMMMMMNNNNNNNOOOOOOOPPPPPPPQQQQQQQRRRRRRRSSSSSSS
▐█ █▌▐█ ▄█▌▐█▄ ▐██ █▌ ▄███▄ ▐████▄ ▄███▄ ▐████▄ ▄███▄
▐█ █▌ ▐█ ▐█ ▐▌ █▌▐██▌ █▌▐█ █▌▐█ █▌▐█ █▌▐█ █▌▐█
▐█▄█▌ ▐█ ▐█ ▐▌ █▌▐█▐█ █▌▐█ █▌▐████▀ ▐█ █▌▐█████ ▀███▄
▐█▀█▌ ▐█ ▐█ █▌▐█ █▌█▌▐█ █▌▐█ ▐█ █▌▐█ █▌ █▌
▐█ █▌ ▐█ ▐█ █▌▐█ ▐██▌▐█ █▌▐█ ▐█ █▌█▌▐█ █▌ █▌
▐█ █▌▐█████▌▐█ █▌▐█ ██▌ ▀███▀ ▐█ ▀███▀ ▐█ █▌ ▀███▀
▀▀
""",
"""
TTTTTTTUUUUUUUVVVVVVVWWWWWWWWXXXXXXXYYYYYYYZZZZZZZ
█████▌▐█ █▌▐█ █▌▐█ █▌▐█ █▌ █▌ █▌▐█████▌
█▌ ▐█ █▌ █▌ ▐█ ▐█ █▌ ▐█ █▌ ▐█ ▐█ █▌
█▌ ▐█ █▌ ▐█ █▌ ▐█ █▌ ▐█▌ ▐██ █▌
█▌ ▐█ █▌ ███ ▐█ ▐▌ █▌ ███ █▌ █▌
█▌ ▐█ █▌ ▐█▌ ▐█ ▐▌ █▌ █▌ ▐█ █▌ █▌
█▌ ▀███▀ █ ▀█▌▐█▀ ▐█ █▌ █▌ ▐█████▌
""",
"""
aaaaaaabbbbbbbcccccccdddddddeeeeeeefffffggggggghhhhhhhiiijjjj
▐█ █▌ ▄█▌ ▐█ █▌ █▌
▐█ █▌ ▐█ ▐█
▄███▄ ▐████▄ ▄███▄ ▄████▌ ▄███▄ ▐███ ▄███▄ ▐████▄ ▐█▌ ▐█▌
▄▄▄█▌▐█ █▌▐█ ▐█ █▌▐█▄▄▄█▌ ▐█ ▐█ █▌▐█ █▌ █▌ █▌
▐█▀▀▀█▌▐█ █▌▐█ ▐█ █▌▐█▀▀▀ ▐█ ▐█▄▄▄█▌▐█ █▌ █▌ █▌
▀████▌▐████▀ ▀███▀ ▀████▌ ▀███▀ ▐█ ▀▀▀█▌▐█ █▌ █▌ █▌
▀███▀ ▐██
""",
"""
kkkkkkkllllmmmmmmmmnnnnnnnooooooopppppppqqqqqqqrrrrrrsssssss
▐█ ██
▐█ ▐█
▐█ ▄█▌ ▐█ ▄█▌▐█▄ ▐████▄ ▄███▄ ▐████▄ ▄████▌ ▄███▌ ▄███▄
▐█▄█▀ ▐█ ▐█ ▐▌ █▌▐█ █▌▐█ █▌▐█ █▌▐█ █▌▐█ ▐█▄▄▄
▐█▀▀█▄ ▐█ ▐█ ▐▌ █▌▐█ █▌▐█ █▌▐█ █▌▐█ █▌▐█ ▀▀▀█▌
▐█ █▌ ▐█▌▐█ █▌▐█ █▌ ▀███▀ ▐████▀ ▀████▌▐█ ▀███▀
▐█ █▌
""",
"""
tttttuuuuuuuvvvvvvvwwwwwwwwxxxxxxxyyyyyyyzzzzzzz
█▌
█▌
███▌▐█ █▌▐█ █▌▐█ █▌▐█ █▌▐█ █▌▐█████▌
█▌ ▐█ █▌ █▌ ▐█ ▐█ █▌ ▀█▄█▀ ▐█ █▌ ▄█▀
█▌ ▐█ █▌ ███ ▐█ ▐▌ █▌ ▄█▀█▄ ▐█▄▄▄█▌ ▄█▀
█▌ ▀███▀ ▐█▌ ▀█▌▐█▀ ▐█ █▌ ▀▀▀█▌▐█████▌
▀███▀
""",
)
if __name__ == "__main__":
all_ascii = frozenset(chr(x) for x in range(32, 127))
print("Available Fonts: (U) = UTF-8 required")
print("----------------")
for n, font_cls in get_all_fonts():
f = font_cls()
u = ""
if f.utf8_required:
u = "(U)"
print(f"{n:<20} {u:>3} ", end=" ")
chars = f.characters()
font_chars = frozenset(chars)
if font_chars == all_ascii:
print("Full ASCII")
elif font_chars & all_ascii == all_ascii:
print(f"Full ASCII + {''.join(font_chars ^ all_ascii)!r}")
else:
print(f"Characters: {chars!r}")
@@ -0,0 +1,96 @@
# Urwid graphics widgets
# Copyright (C) 2004-2011 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
from urwid.display import AttrSpec
from urwid.widget import (
BarGraph,
BarGraphError,
BarGraphMeta,
BigText,
GraphVScale,
LineBox,
ProgressBar,
Sizing,
Text,
Widget,
fixed_size,
scale_bar_values,
)
__all__ = (
"BarGraph",
"BarGraphError",
"BarGraphMeta",
"BigText",
"GraphVScale",
"LineBox",
"ProgressBar",
"scale_bar_values",
)
class PythonLogo(Widget):
_sizing = frozenset([Sizing.FIXED])
def __init__(self) -> None:
"""
Create canvas containing an ASCII version of the Python
Logo and store it.
"""
super().__init__()
blu = AttrSpec("light blue", "default")
yel = AttrSpec("yellow", "default")
width = 17
# fmt: off
self._canvas = Text(
[
(blu, " ______\n"),
(blu, " _|_o__ |"), (yel, "__\n"),
(blu, " | _____|"), (yel, " |\n"),
(blu, " |__| "), (yel, "______|\n"),
(yel, " |____o_|"),
]
).render((width,))
# fmt: on
def pack(self, size=None, focus: bool = False):
"""
Return the size from our pre-rendered canvas.
"""
return self._canvas.cols(), self._canvas.rows()
def render(self, size, focus: bool = False):
"""
Return the pre-rendered canvas.
"""
fixed_size(size)
return self._canvas
def _test():
import doctest
doctest.testmod()
if __name__ == "__main__":
_test()
@@ -0,0 +1,380 @@
#
# Urwid basic widget classes
# Copyright (C) 2004-2012 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
import re
import warnings
from decimal import Decimal
from typing import TYPE_CHECKING
from urwid import Edit
if TYPE_CHECKING:
from collections.abc import Container
class NumEdit(Edit):
"""NumEdit - edit numerical types
based on the characters in 'allowed' different numerical types
can be edited:
+ regular int: 0123456789
+ regular float: 0123456789.
+ regular oct: 01234567
+ regular hex: 0123456789abcdef
"""
ALLOWED = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
def __init__(
self,
allowed: Container[str],
caption,
default: str | bytes,
trimLeadingZeros: bool | None = None,
*,
trim_leading_zeros: bool = True,
allow_negative: bool = False,
):
super().__init__(caption, default)
self._allowed = allowed
self._trim_leading_zeros = trim_leading_zeros
self._allow_negative = allow_negative
if trimLeadingZeros is not None:
warnings.warn(
"'trimLeadingZeros' argument is deprecated. Use 'trim_leading_zeros' keyword argument",
DeprecationWarning,
stacklevel=3,
)
self._trim_leading_zeros = trimLeadingZeros
def valid_char(self, ch: str) -> bool:
"""
Return true for allowed characters.
"""
if len(ch) == 1:
if ch.upper() in self._allowed:
return True
return self._allow_negative and ch == "-" and self.edit_pos == 0 and "-" not in self.edit_text
return False
def keypress(
self,
size: tuple[int], # type: ignore[override]
key: str,
) -> str | None:
"""
Handle editing keystrokes. Remove leading zeros.
>>> e, size = NumEdit("0123456789", "", "5002"), (10,)
>>> e.keypress(size, 'home')
>>> e.keypress(size, 'delete')
>>> assert e.edit_text == "002"
>>> e.keypress(size, 'end')
>>> assert e.edit_text == "2"
>>> # binary only
>>> e, size = NumEdit("01", "", ""), (10,)
>>> assert e.edit_text == ""
>>> e.keypress(size, '1')
>>> e.keypress(size, '0')
>>> e.keypress(size, '1')
>>> assert e.edit_text == "101"
>>> e, size = NumEdit("0123456789", "", "", allow_negative=True), (10,)
>>> e.keypress(size, "-")
>>> e.keypress(size, '1')
>>> e.edit_text
'-1'
>>> e.keypress(size, 'home')
>>> e.keypress(size, 'delete')
>>> e.edit_text
'1'
>>> e.keypress(size, 'end')
>>> e.keypress(size, "-")
'-'
>>> e.edit_text
'1'
"""
unhandled = super().keypress(size, key)
if not unhandled and self._trim_leading_zeros:
# trim leading zeros
while self.edit_pos > 0 and self.edit_text[:1] == "0":
self.set_edit_pos(self.edit_pos - 1)
self.set_edit_text(self.edit_text[1:])
return unhandled
class IntegerEdit(NumEdit):
"""Edit widget for integer values"""
def __init__(
self,
caption="",
default: int | str | Decimal | None = None,
base: int = 10,
*,
allow_negative: bool = False,
) -> None:
"""
caption -- caption markup
default -- default edit value
>>> IntegerEdit(u"", 42)
<IntegerEdit selectable flow widget '42' edit_pos=2>
>>> e, size = IntegerEdit(u"", "5002"), (10,)
>>> e.keypress(size, 'home')
>>> e.keypress(size, 'delete')
>>> assert e.edit_text == "002"
>>> e.keypress(size, 'end')
>>> assert e.edit_text == "2"
>>> e.keypress(size, '9')
>>> e.keypress(size, '0')
>>> assert e.edit_text == "290"
>>> e, size = IntegerEdit("", ""), (10,)
>>> assert e.value() is None
>>> # binary
>>> e, size = IntegerEdit(u"", "1010", base=2), (10,)
>>> e.keypress(size, 'end')
>>> e.keypress(size, '1')
>>> assert e.edit_text == "10101"
>>> assert e.value() == Decimal("21")
>>> # HEX
>>> e, size = IntegerEdit(u"", "10", base=16), (10,)
>>> e.keypress(size, 'end')
>>> e.keypress(size, 'F')
>>> e.keypress(size, 'f')
>>> assert e.edit_text == "10Ff"
>>> assert e.keypress(size, 'G') == 'G' # unhandled key
>>> assert e.edit_text == "10Ff"
>>> # keep leading 0's when not base 10
>>> e, size = IntegerEdit(u"", "10FF", base=16), (10,)
>>> assert e.edit_text == "10FF"
>>> assert e.value() == Decimal("4351")
>>> e.keypress(size, 'home')
>>> e.keypress(size, 'delete')
>>> e.keypress(size, '0')
>>> assert e.edit_text == "00FF"
>>> # test exception on incompatible value for base
>>> e, size = IntegerEdit(u"", "10FG", base=16), (10,)
Traceback (most recent call last):
...
ValueError: invalid value: 10FG for base 16
>>> # test exception on float init value
>>> e, size = IntegerEdit(u"", 10.0), (10,)
Traceback (most recent call last):
...
ValueError: default: Only 'str', 'int', 'long' or Decimal input allowed
>>> e, size = IntegerEdit(u"", Decimal("10.0")), (10,)
Traceback (most recent call last):
...
ValueError: not an 'integer Decimal' instance
"""
self.base = base
val = ""
allowed_chars = self.ALLOWED[: self.base]
if default is not None:
if not isinstance(default, (int, str, Decimal)):
raise ValueError("default: Only 'str', 'int' or Decimal input allowed")
# convert to a long first, this will raise a ValueError
# in case a float is passed or some other error
if isinstance(default, str) and len(default):
# check if it is a valid initial value
validation_re = f"^[{allowed_chars}]+$"
if not re.match(validation_re, str(default), re.IGNORECASE):
raise ValueError(f"invalid value: {default} for base {base}")
elif isinstance(default, Decimal) and default.as_tuple()[2] != 0:
# a Decimal instance with no fractional part
raise ValueError("not an 'integer Decimal' instance")
# convert possible int, long or Decimal to str
val = str(default)
super().__init__(
allowed_chars,
caption,
val,
trim_leading_zeros=(self.base == 10),
allow_negative=allow_negative,
)
def value(self) -> Decimal | None:
"""
Return the numeric value of self.edit_text.
>>> e, size = IntegerEdit(), (10,)
>>> e.keypress(size, '5')
>>> e.keypress(size, '1')
>>> assert e.value() == 51
"""
if self.edit_text:
return Decimal(int(self.edit_text, self.base))
return None
def __int__(self) -> int:
"""Enforced int value return.
>>> e, size = IntegerEdit(allow_negative=True), (10,)
>>> assert int(e) == 0
>>> e.keypress(size, '-')
>>> e.keypress(size, '4')
>>> e.keypress(size, '2')
>>> assert int(e) == -42
"""
if self.edit_text:
return int(self.edit_text, self.base)
return 0
class FloatEdit(NumEdit):
"""Edit widget for float values."""
def __init__(
self,
caption="",
default: str | int | Decimal | None = None,
preserveSignificance: bool | None = None,
decimalSeparator: str | None = None,
*,
preserve_significance: bool = True,
decimal_separator: str = ".",
allow_negative: bool = False,
) -> None:
"""
caption -- caption markup
default -- default edit value
preserve_significance -- return value has the same signif. as default
decimal_separator -- use '.' as separator by default, optionally a ','
>>> FloatEdit(u"", "1.065434")
<FloatEdit selectable flow widget '1.065434' edit_pos=8>
>>> e, size = FloatEdit(u"", "1.065434"), (10,)
>>> e.keypress(size, 'home')
>>> e.keypress(size, 'delete')
>>> assert e.edit_text == ".065434"
>>> e.keypress(size, 'end')
>>> e.keypress(size, 'backspace')
>>> assert e.edit_text == ".06543"
>>> e, size = FloatEdit(), (10,)
>>> e.keypress(size, '5')
>>> e.keypress(size, '1')
>>> e.keypress(size, '.')
>>> e.keypress(size, '5')
>>> e.keypress(size, '1')
>>> assert e.value() == Decimal("51.51"), e.value()
>>> e, size = FloatEdit(decimal_separator=":"), (10,)
Traceback (most recent call last):
...
ValueError: invalid decimal separator: :
>>> e, size = FloatEdit(decimal_separator=","), (10,)
>>> e.keypress(size, '5')
>>> e.keypress(size, '1')
>>> e.keypress(size, ',')
>>> e.keypress(size, '5')
>>> e.keypress(size, '1')
>>> assert e.edit_text == "51,51"
>>> e, size = FloatEdit("", "3.1415", preserve_significance=True), (10,)
>>> e.keypress(size, 'end')
>>> e.keypress(size, 'backspace')
>>> e.keypress(size, 'backspace')
>>> assert e.edit_text == "3.14"
>>> assert e.value() == Decimal("3.1400")
>>> e.keypress(size, '1')
>>> e.keypress(size, '5')
>>> e.keypress(size, '9')
>>> assert e.value() == Decimal("3.1416"), e.value()
>>> e, size = FloatEdit("", ""), (10,)
>>> assert e.value() is None
>>> e, size = FloatEdit(u"", 10.0), (10,)
Traceback (most recent call last):
...
ValueError: default: Only 'str', 'int', 'long' or Decimal input allowed
"""
self.significance = None
self._decimal_separator = decimal_separator
if decimalSeparator is not None:
warnings.warn(
"'decimalSeparator' argument is deprecated. Use 'decimal_separator' keyword argument",
DeprecationWarning,
stacklevel=3,
)
self._decimal_separator = decimalSeparator
if self._decimal_separator not in {".", ","}:
raise ValueError(f"invalid decimal separator: {self._decimal_separator}")
if preserveSignificance is not None:
warnings.warn(
"'preserveSignificance' argument is deprecated. Use 'preserve_significance' keyword argument",
DeprecationWarning,
stacklevel=3,
)
preserve_significance = preserveSignificance
val = ""
if default is not None and default != "": # noqa: PLC1901,RUF100
if not isinstance(default, (int, str, Decimal)):
raise ValueError("default: Only 'str', 'int' or Decimal input allowed")
if isinstance(default, str) and default:
# check if it is a float, raises a ValueError otherwise
float(default)
default = Decimal(default)
if preserve_significance and isinstance(default, Decimal):
self.significance = default
val = str(default)
super().__init__(self.ALLOWED[0:10] + self._decimal_separator, caption, val, allow_negative=allow_negative)
def value(self) -> Decimal | None:
"""
Return the numeric value of self.edit_text.
"""
if self.edit_text:
normalized = Decimal(self.edit_text.replace(self._decimal_separator, "."))
if self.significance is not None:
return normalized.quantize(self.significance)
return normalized
return None
def __float__(self) -> float:
"""Enforced float value return.
>>> e, size = FloatEdit(allow_negative=True), (10,)
>>> assert float(e) == 0.
>>> e.keypress(size, '-')
>>> e.keypress(size, '4')
>>> e.keypress(size, '.')
>>> e.keypress(size, '2')
>>> assert float(e) == -4.2
"""
if self.edit_text:
return float(self.edit_text.replace(self._decimal_separator, "."))
return 0.0
@@ -0,0 +1,331 @@
# Urwid signal dispatching
# Copyright (C) 2004-2012 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
import abc
import itertools
import typing
import warnings
import weakref
if typing.TYPE_CHECKING:
from collections.abc import Callable, Collection, Container, Hashable, Iterable
class MetaSignals(abc.ABCMeta):
"""
register the list of signals in the class variable signals,
including signals in superclasses.
"""
def __init__(cls, name: str, bases: tuple[type, ...], d: dict[str, typing.Any]) -> None:
signals = d.get("signals", [])
for superclass in cls.__bases__:
signals.extend(getattr(superclass, "signals", []))
signals = list(dict.fromkeys(signals).keys())
d["signals"] = signals
register_signal(cls, signals)
super().__init__(name, bases, d)
def setdefaultattr(obj, name, value):
# like dict.setdefault() for object attributes
if hasattr(obj, name):
return getattr(obj, name)
setattr(obj, name, value)
return value
class Key:
"""
Minimal class, whose only purpose is to produce objects with a
unique hash
"""
__slots__ = ()
class Signals:
_signal_attr = "_urwid_signals" # attribute to attach to signal senders
def __init__(self) -> None:
self._supported = {}
def register(self, sig_cls, signals: Container[Hashable]) -> None:
"""
:param sig_cls: the class of an object that will be sending signals
:type sig_cls: class
:param signals: a list of signals that may be sent, typically each
signal is represented by a string
:type signals: signal names
This function must be called for a class before connecting any
signal callbacks or emitting any signals from that class' objects
"""
self._supported[sig_cls] = signals
def connect(
self,
obj,
name: Hashable,
callback: Callable[..., typing.Any],
user_arg: typing.Any = None,
*,
weak_args: Iterable[typing.Any] = (),
user_args: Iterable[typing.Any] = (),
) -> Key:
"""
:param obj: the object sending a signal
:type obj: object
:param name: the signal to listen for, typically a string
:type name: signal name
:param callback: the function to call when that signal is sent
:type callback: function
:param user_arg: deprecated additional argument to callback (appended
after the arguments passed when the signal is
emitted). If None no arguments will be added.
Don't use this argument, use user_args instead.
:param weak_args: additional arguments passed to the callback
(before any arguments passed when the signal
is emitted and before any user_args).
These arguments are stored as weak references
(but converted back into their original value
before passing them to callback) to prevent
any objects referenced (indirectly) from
weak_args from being kept alive just because
they are referenced by this signal handler.
Use this argument only as a keyword argument,
since user_arg might be removed in the future.
:type weak_args: iterable
:param user_args: additional arguments to pass to the callback,
(before any arguments passed when the signal
is emitted but after any weak_args).
Use this argument only as a keyword argument,
since user_arg might be removed in the future.
:type user_args: iterable
When a matching signal is sent, callback will be called. The
arguments it receives will be the user_args passed at connect
time (as individual arguments) followed by all the positional
parameters sent with the signal.
As an example of using weak_args, consider the following snippet:
>>> import urwid
>>> debug = urwid.Text('')
>>> def handler(widget, newtext):
... debug.set_text("Edit widget changed to %s" % newtext)
>>> edit = urwid.Edit('')
>>> key = urwid.connect_signal(edit, 'change', handler)
If you now build some interface using "edit" and "debug", the
"debug" widget will show whatever you type in the "edit" widget.
However, if you remove all references to the "debug" widget, it
will still be kept alive by the signal handler. This because the
signal handler is a closure that (implicitly) references the
"edit" widget. If you want to allow the "debug" widget to be
garbage collected, you can create a "fake" or "weak" closure
(it's not really a closure, since it doesn't reference any
outside variables, so it's just a dynamic function):
>>> debug = urwid.Text('')
>>> def handler(weak_debug, widget, newtext):
... weak_debug.set_text("Edit widget changed to %s" % newtext)
>>> edit = urwid.Edit('')
>>> key = urwid.connect_signal(edit, 'change', handler, weak_args=[debug])
Here the weak_debug parameter in print_debug is the value passed
in the weak_args list to connect_signal. Note that the
weak_debug value passed is not a weak reference anymore, the
signals code transparently dereferences the weakref parameter
before passing it to print_debug.
Returns a key associated by this signal handler, which can be
used to disconnect the signal later on using
urwid.disconnect_signal_by_key. Alternatively, the signal
handler can also be disconnected by calling
urwid.disconnect_signal, which doesn't need this key.
"""
if user_arg is not None:
warnings.warn(
"Don't use user_arg argument, use user_args instead.",
DeprecationWarning,
stacklevel=2,
)
sig_cls = obj.__class__
if name not in self._supported.get(sig_cls, ()):
raise NameError(f"No such signal {name!r} for object {obj!r}")
# Just generate an arbitrary (but unique) key
key = Key()
handlers = setdefaultattr(obj, self._signal_attr, {}).setdefault(name, [])
# Remove the signal handler when any of the weakref'd arguments
# are garbage collected. Note that this means that the handlers
# dictionary can be modified _at any time_, so it should never
# be iterated directly (e.g. iterate only over .keys() and
# .items(), never over .iterkeys(), .iteritems() or the object
# itself).
# We let the callback keep a weakref to the object as well, to
# prevent a circular reference between the handler and the
# object (via the weakrefs, which keep strong references to
# their callbacks) from existing.
obj_weak = weakref.ref(obj)
def weakref_callback(weakref): # pylint: disable=redefined-outer-name # bad, but not changing API
o = obj_weak()
if o:
self.disconnect_by_key(o, name, key)
user_args = self._prepare_user_args(weak_args, user_args, weakref_callback)
handlers.append((key, callback, user_arg, user_args))
return key
def _prepare_user_args(
self,
weak_args: Iterable[typing.Any] = (),
user_args: Iterable[typing.Any] = (),
callback: Callable[..., typing.Any] | None = None,
) -> tuple[Collection[weakref.ReferenceType], Collection[typing.Any]]:
# Turn weak_args into weakrefs and prepend them to user_args
w_args = tuple(weakref.ref(w_arg, callback) for w_arg in weak_args)
args = tuple(user_args) or ()
return (w_args, args)
def disconnect(
self,
obj,
name: Hashable,
callback: Callable[..., typing.Any],
user_arg: typing.Any = None,
*,
weak_args: Iterable[typing.Any] = (),
user_args: Iterable[typing.Any] = (),
) -> None:
"""
:param obj: the object to disconnect the signal from
:type obj: object
:param name: the signal to disconnect, typically a string
:type name: signal name
:param callback: the callback function passed to connect_signal
:type callback: function
:param user_arg: the user_arg parameter passed to connect_signal
:param weak_args: the weak_args parameter passed to connect_signal
:param user_args: the weak_args parameter passed to connect_signal
This function will remove a callback from the list connected
to a signal with connect_signal(). The arguments passed should
be exactly the same as those passed to connect_signal().
If the callback is not connected or already disconnected, this
function will simply do nothing.
"""
signals = setdefaultattr(obj, self._signal_attr, {})
if name not in signals:
return None
handlers = signals[name]
# Do the same processing as in connect, so we can compare the
# resulting tuple.
user_args = self._prepare_user_args(weak_args, user_args)
# Remove the given handler
for h in handlers:
if h[1:] == (callback, user_arg, user_args):
return self.disconnect_by_key(obj, name, h[0])
return None
def disconnect_by_key(self, obj, name: Hashable, key: Key) -> None:
"""
:param obj: the object to disconnect the signal from
:type obj: object
:param name: the signal to disconnect, typically a string
:type name: signal name
:param key: the key for this signal handler, as returned by
connect_signal().
:type key: Key
This function will remove a callback from the list connected
to a signal with connect_signal(). The key passed should be the
value returned by connect_signal().
If the callback is not connected or already disconnected, this
function will simply do nothing.
"""
handlers = setdefaultattr(obj, self._signal_attr, {}).get(name, [])
handlers[:] = [h for h in handlers if h[0] is not key]
def emit(self, obj, name: Hashable, *args) -> bool:
"""
:param obj: the object sending a signal
:type obj: object
:param name: the signal to send, typically a string
:type name: signal name
:param args: zero or more positional arguments to pass to the signal callback functions
This function calls each of the callbacks connected to this signal
with the args arguments as positional parameters.
This function returns True if any of the callbacks returned True.
"""
result = False
handlers = getattr(obj, self._signal_attr, {}).get(name, [])
for _key, callback, user_arg, (weak_args, user_args) in handlers:
result |= self._call_callback(callback, user_arg, weak_args, user_args, args)
return result
def _call_callback(
self,
callback,
user_arg: typing.Any,
weak_args: Collection[weakref.ReferenceType],
user_args: Collection[typing.Any],
emit_args: Iterable[typing.Any],
) -> bool:
args_to_pass = []
for w_arg in weak_args:
real_arg = w_arg()
if real_arg is not None:
args_to_pass.append(real_arg)
else:
# de-referenced
return False
# The deprecated user_arg argument was added to the end
# instead of the beginning.
args = itertools.chain(args_to_pass, user_args, emit_args, (user_arg,) if user_arg is not None else ())
return bool(callback(*args))
_signals = Signals()
emit_signal = _signals.emit
register_signal = _signals.register
connect_signal = _signals.connect
disconnect_signal = _signals.disconnect
disconnect_signal_by_key = _signals.disconnect_by_key
@@ -0,0 +1,130 @@
# Urwid split_repr helper functions
# Copyright (C) 2004-2011 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
from inspect import getfullargspec
def split_repr(self):
"""
Return a helpful description of the object using
self._repr_words() and self._repr_attrs() to add
to the description. This function may be used by
adding code to your class like this:
>>> class Foo(object):
... __repr__ = split_repr
... def _repr_words(self):
... return ["words", "here"]
... def _repr_attrs(self):
... return {'attrs': "appear too"}
>>> Foo()
<Foo words here attrs='appear too'>
>>> class Bar(Foo):
... def _repr_words(self):
... return Foo._repr_words(self) + ["too"]
... def _repr_attrs(self):
... return dict(Foo._repr_attrs(self), barttr=42)
>>> Bar()
<Bar words here too attrs='appear too' barttr=42>
"""
alist = sorted((str(k), normalize_repr(v)) for k, v in self._repr_attrs().items())
words = self._repr_words()
if not words and not alist:
# if we're just going to print the classname fall back
# to the previous __repr__ implementation instead
return super(self.__class__, self).__repr__()
if words and alist:
words.append("")
return f"<{self.__class__.__name__} {' '.join(words) + ' '.join([f'{k}={v}' for k, v in alist])}>"
def normalize_repr(v):
"""
Return dictionary repr sorted by keys, leave others unchanged
>>> normalize_repr({1:2,3:4,5:6,7:8})
'{1: 2, 3: 4, 5: 6, 7: 8}'
>>> normalize_repr('foo')
"'foo'"
"""
if isinstance(v, dict):
items = sorted((repr(k), repr(v)) for k, v in v.items())
return f"{{{', '.join(f'{k}: {v}' for k, v in items)}}}"
return repr(v)
def remove_defaults(d, fn):
"""
Remove keys in d that are set to the default values from
fn. This method is used to unclutter the _repr_attrs()
return value.
d will be modified by this function.
Returns d.
>>> class Foo(object):
... def __init__(self, a=1, b=2):
... self.values = a, b
... __repr__ = split_repr
... def _repr_words(self):
... return ["object"]
... def _repr_attrs(self):
... d = dict(a=self.values[0], b=self.values[1])
... return remove_defaults(d, Foo.__init__)
>>> Foo(42, 100)
<Foo object a=42 b=100>
>>> Foo(10, 2)
<Foo object a=10>
>>> Foo()
<Foo object>
"""
args, varargs, varkw, defaults, _, _, _ = getfullargspec(fn)
# ignore *varargs and **kwargs
if varkw:
del args[-1]
if varargs:
del args[-1]
# create a dictionary of args with default values
ddict = dict(zip(args[len(args) - len(defaults) :], defaults))
for k in list(d.keys()):
if k in ddict and ddict[k] == d[k]:
# remove values that match their defaults
del d[k]
return d
def _test():
import doctest
doctest.testmod()
if __name__ == "__main__":
_test()
@@ -0,0 +1,354 @@
# Urwid unicode character processing tables
# Copyright (C) 2004-2011 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
import re
import typing
import warnings
import wcwidth
if typing.TYPE_CHECKING:
from typing_extensions import Literal
SAFE_ASCII_RE = re.compile(r"^[ -~]*$")
SAFE_ASCII_BYTES_RE = re.compile(rb"^[ -~]*$")
_byte_encoding: Literal["utf8", "narrow", "wide"] = "narrow"
def get_char_width(char: str) -> Literal[0, 1, 2]:
if (width := wcwidth.wcwidth(char)) >= 0:
return width
return 0
def get_width(o: int) -> Literal[0, 1, 2]:
"""Return the screen column width for unicode ordinal o."""
return get_char_width(chr(o))
def decode_one(text: bytes | str, pos: int) -> tuple[int, int]:
"""
Return (ordinal at pos, next position) for UTF-8 encoded text.
"""
lt = len(text) - pos
b2 = 0 # Fallback, not changing anything
b3 = 0 # Fallback, not changing anything
b4 = 0 # Fallback, not changing anything
try:
if isinstance(text, str):
b1 = ord(text[pos])
if lt > 1:
b2 = ord(text[pos + 1])
if lt > 2:
b3 = ord(text[pos + 2])
if lt > 3:
b4 = ord(text[pos + 3])
else:
b1 = text[pos]
if lt > 1:
b2 = text[pos + 1]
if lt > 2:
b3 = text[pos + 2]
if lt > 3:
b4 = text[pos + 3]
except Exception as e:
raise ValueError(f"{e}: text={text!r}, pos={pos!r}, lt={lt!r}").with_traceback(e.__traceback__) from e
if not b1 & 0x80:
return b1, pos + 1
error = ord("?"), pos + 1
if lt < 2:
return error
if b1 & 0xE0 == 0xC0:
if b2 & 0xC0 != 0x80:
return error
if (o := ((b1 & 0x1F) << 6) | (b2 & 0x3F)) >= 0x80:
return o, pos + 2
return error
if lt < 3:
return error
if b1 & 0xF0 == 0xE0:
if b2 & 0xC0 != 0x80:
return error
if b3 & 0xC0 != 0x80:
return error
if (o := ((b1 & 0x0F) << 12) | ((b2 & 0x3F) << 6) | (b3 & 0x3F)) >= 0x800:
return o, pos + 3
return error
if lt < 4:
return error
if b1 & 0xF8 == 0xF0:
if b2 & 0xC0 != 0x80:
return error
if b3 & 0xC0 != 0x80:
return error
if b4 & 0xC0 != 0x80:
return error
if (o := ((b1 & 0x07) << 18) | ((b2 & 0x3F) << 12) | ((b3 & 0x3F) << 6) | (b4 & 0x3F)) >= 0x10000:
return o, pos + 4
return error
return error
def decode_one_uni(text: str, i: int) -> tuple[int, int]:
"""
decode_one implementation for unicode strings
"""
return ord(text[i]), i + 1
def decode_one_right(text: bytes, pos: int) -> tuple[int, int] | None:
"""
Return (ordinal at pos, next position) for UTF-8 encoded text.
pos is assumed to be on the trailing byte of a utf-8 sequence.
"""
if not isinstance(text, bytes):
raise TypeError(text)
error = ord("?"), pos - 1
p = pos
while p >= 0:
if text[p] & 0xC0 != 0x80:
o, _next_pos = decode_one(text, p)
return o, p - 1
p -= 1
if p == p - 4:
return error
return None
def set_byte_encoding(enc: Literal["utf8", "narrow", "wide"]) -> None:
if enc not in {"utf8", "narrow", "wide"}:
raise ValueError(enc)
global _byte_encoding # noqa: PLW0603 # pylint: disable=global-statement
_byte_encoding = enc
def get_byte_encoding() -> Literal["utf8", "narrow", "wide"]:
return _byte_encoding
def calc_string_text_pos(text: str, start_offs: int, end_offs: int, pref_col: int) -> tuple[int, int]:
"""
Calculate the closest position to the screen column pref_col in text
where start_offs is the offset into text assumed to be screen column 0
and end_offs is the end of the range to search.
:param text: string
:param start_offs: starting text position
:param end_offs: ending text position
:param pref_col: target column
:returns: (position, actual_col)
..note:: this method is a simplified version of `wcwidth.wcswidth` and ideally should be in wcwidth package.
"""
if start_offs > end_offs:
raise ValueError((start_offs, end_offs))
cols = 0
for idx in range(start_offs, end_offs):
width = get_char_width(text[idx])
if width + cols > pref_col:
return idx, cols
cols += width
return end_offs, cols
def calc_text_pos(text: str | bytes, start_offs: int, end_offs: int, pref_col: int) -> tuple[int, int]:
"""
Calculate the closest position to the screen column pref_col in text
where start_offs is the offset into text assumed to be screen column 0
and end_offs is the end of the range to search.
text may be unicode or a byte string in the target _byte_encoding
Returns (position, actual_col).
"""
if start_offs > end_offs:
raise ValueError((start_offs, end_offs))
if isinstance(text, str):
return calc_string_text_pos(text, start_offs, end_offs, pref_col)
if not isinstance(text, bytes):
raise TypeError(text)
if _byte_encoding == "utf8":
i = start_offs
sc = 0
while i < end_offs:
o, n = decode_one(text, i)
w = get_width(o)
if w + sc > pref_col:
return i, sc
i = n
sc += w
return i, sc
# "wide" and "narrow"
i = start_offs + pref_col
if i >= end_offs:
return end_offs, end_offs - start_offs
if _byte_encoding == "wide" and within_double_byte(text, start_offs, i) == 2:
i -= 1
return i, i - start_offs
def calc_width(text: str | bytes, start_offs: int, end_offs: int) -> int:
"""
Return the screen column width of text between start_offs and end_offs.
text may be unicode or a byte string in the target _byte_encoding
Some characters are wide (take two columns) and others affect the
previous character (take zero columns). Use the widths table above
to calculate the screen column width of text[start_offs:end_offs]
"""
if start_offs > end_offs:
raise ValueError((start_offs, end_offs))
if isinstance(text, str):
return sum(get_char_width(char) for char in text[start_offs:end_offs])
if _byte_encoding == "utf8":
try:
return sum(get_char_width(char) for char in text[start_offs:end_offs].decode("utf-8"))
except UnicodeDecodeError as exc:
warnings.warn(
"`calc_width` with text encoded to bytes can produce incorrect results"
f"due to possible offset in the middle of character: {exc}",
UnicodeWarning,
stacklevel=2,
)
i = start_offs
sc = 0
while i < end_offs:
o, i = decode_one(text, i)
w = get_width(o)
sc += w
return sc
# "wide", "narrow" or all printable ASCII, just return the character count
return end_offs - start_offs
def is_wide_char(text: str | bytes, offs: int) -> bool:
"""
Test if the character at offs within text is wide.
text may be unicode or a byte string in the target _byte_encoding
"""
if isinstance(text, str):
return get_char_width(text[offs]) == 2
if not isinstance(text, bytes):
raise TypeError(text)
if _byte_encoding == "utf8":
o, _n = decode_one(text, offs)
return get_width(o) == 2
if _byte_encoding == "wide":
return within_double_byte(text, offs, offs) == 1
return False
def move_prev_char(text: str | bytes, start_offs: int, end_offs: int) -> int:
"""
Return the position of the character before end_offs.
"""
if start_offs >= end_offs:
raise ValueError((start_offs, end_offs))
if isinstance(text, str):
return end_offs - 1
if not isinstance(text, bytes):
raise TypeError(text)
if _byte_encoding == "utf8":
o = end_offs - 1
while text[o] & 0xC0 == 0x80:
o -= 1
return o
if _byte_encoding == "wide" and within_double_byte(text, start_offs, end_offs - 1) == 2:
return end_offs - 2
return end_offs - 1
def move_next_char(text: str | bytes, start_offs: int, end_offs: int) -> int:
"""
Return the position of the character after start_offs.
"""
if start_offs >= end_offs:
raise ValueError((start_offs, end_offs))
if isinstance(text, str):
return start_offs + 1
if not isinstance(text, bytes):
raise TypeError(text)
if _byte_encoding == "utf8":
o = start_offs + 1
while o < end_offs and text[o] & 0xC0 == 0x80:
o += 1
return o
if _byte_encoding == "wide" and within_double_byte(text, start_offs, start_offs) == 1:
return start_offs + 2
return start_offs + 1
def within_double_byte(text: bytes, line_start: int, pos: int) -> Literal[0, 1, 2]:
"""Return whether pos is within a double-byte encoded character.
text -- byte string in question
line_start -- offset of beginning of line (< pos)
pos -- offset in question
Return values:
0 -- not within dbe char, or double_byte_encoding == False
1 -- pos is on the 1st half of a dbe char
2 -- pos is on the 2nd half of a dbe char
"""
if not isinstance(text, bytes):
raise TypeError(text)
v = text[pos]
if 0x40 <= v < 0x7F:
# might be second half of big5, uhc or gbk encoding
if pos == line_start:
return 0
if text[pos - 1] >= 0x81 and within_double_byte(text, line_start, pos - 1) == 1:
return 2
return 0
if v < 0x80:
return 0
i = pos - 1
while i >= line_start:
if text[i] < 0x80:
break
i -= 1
if (pos - i) & 1:
return 1
return 2
@@ -0,0 +1,641 @@
# Urwid Text Layout classes
# Copyright (C) 2004-2011 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
import functools
import typing
from urwid.str_util import calc_text_pos, calc_width, get_char_width, is_wide_char, move_next_char, move_prev_char
from urwid.util import calc_trim_text, get_encoding
if typing.TYPE_CHECKING:
from typing_extensions import Literal
from urwid.widget import Align, WrapMode
@functools.lru_cache(maxsize=4)
def get_ellipsis_string(encoding: str) -> str:
"""Get ellipsis character for given encoding."""
try:
return "…".encode(encoding).decode(encoding)
except UnicodeEncodeError:
return "..."
@functools.lru_cache(maxsize=4)
def _get_width(string) -> int:
"""Get ellipsis character width for given encoding."""
return sum(get_char_width(char) for char in string)
class TextLayout:
def supports_align_mode(self, align: Literal["left", "center", "right"] | Align) -> bool:
"""Return True if align is a supported align mode."""
return True
def supports_wrap_mode(self, wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode) -> bool:
"""Return True if wrap is a supported wrap mode."""
return True
def layout(
self,
text: str | bytes,
width: int,
align: Literal["left", "center", "right"] | Align,
wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,
) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:
"""
Return a layout structure for text.
:param text: string in current encoding or unicode string
:param width: number of screen columns available
:param align: align mode for text
:param wrap: wrap mode for text
Layout structure is a list of line layouts, one per output line.
Line layouts are lists than may contain the following tuples:
* (column width of text segment, start offset, end offset)
* (number of space characters to insert, offset or None)
* (column width of insert text, offset, "insert text")
The offset in the last two tuples is used to determine the
attribute used for the inserted spaces or text respectively.
The attribute used will be the same as the attribute at that
text offset. If the offset is None when inserting spaces
then no attribute will be used.
"""
raise NotImplementedError(
"This function must be overridden by a real text layout class. (see StandardTextLayout)"
)
class CanNotDisplayText(Exception):
pass
class StandardTextLayout(TextLayout):
def __init__(self) -> None: # , tab_stops=(), tab_stop_every=8):
pass
# """
# tab_stops -- list of screen column indexes for tab stops
# tab_stop_every -- repeated interval for following tab stops
# """
# assert tab_stop_every is None or type(tab_stop_every)==int
# if not tab_stops and tab_stop_every:
# self.tab_stops = (tab_stop_every,)
# self.tab_stops = tab_stops
# self.tab_stop_every = tab_stop_every
def supports_align_mode(self, align: Literal["left", "center", "right"] | Align) -> bool:
"""Return True if align is 'left', 'center' or 'right'."""
return align in {"left", "center", "right"}
def supports_wrap_mode(self, wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode) -> bool:
"""Return True if wrap is 'any', 'space', 'clip' or 'ellipsis'."""
return wrap in {"any", "space", "clip", "ellipsis"}
def layout(
self,
text: str | bytes,
width: int,
align: Literal["left", "center", "right"] | Align,
wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,
) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:
"""Return a layout structure for text."""
try:
segs = self.calculate_text_segments(text, width, wrap)
return self.align_layout(text, width, segs, wrap, align)
except CanNotDisplayText:
return [[]]
def pack(
self,
maxcol: int,
layout: list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]],
) -> int:
"""Return a minimal maxcol value that would result in the same number of lines for layout.
layout must be a layout structure returned by self.layout().
"""
maxwidth = 0
if not layout:
raise ValueError(f"huh? empty layout?: {layout!r}")
for lines in layout:
lw = line_width(lines)
if lw >= maxcol:
return maxcol
maxwidth = max(maxwidth, lw)
return maxwidth
def align_layout(
self,
text: str | bytes,
width: int,
segs: list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]],
wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,
align: Literal["left", "center", "right"] | Align,
) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:
"""Convert the layout segments to an aligned layout."""
out = []
for lines in segs:
sc = line_width(lines)
if sc == width or align == "left":
out.append(lines)
continue
if align == "right":
out.append([(width - sc, None), *lines])
continue
if align != "center":
raise ValueError(align)
pad_trim_left = (width - sc + 1) // 2
out.append([(pad_trim_left, None), *lines] if pad_trim_left else lines)
return out
def _calculate_trimmed_segments(
self,
text: str | bytes,
width: int,
wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,
) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:
"""Calculate text segments for cases of a text trimmed (wrap is clip or ellipsis)."""
segments = []
nl: str | bytes = "\n" if isinstance(text, str) else b"\n"
encoding = get_encoding()
ellipsis_string = get_ellipsis_string(encoding)
ellipsis_width = _get_width(ellipsis_string)
while width - 1 < ellipsis_width and ellipsis_string:
ellipsis_string = ellipsis_string[:-1]
ellipsis_width = _get_width(ellipsis_string)
ellipsis_char = ellipsis_string.encode(encoding)
idx = 0
while idx <= len(text):
nl_pos = text.find(nl, idx)
if nl_pos == -1:
nl_pos = len(text)
screen_columns = calc_width(text, idx, nl_pos)
# trim line to max width if needed, add ellipsis if trimmed
if wrap == "ellipsis" and screen_columns > width and ellipsis_width:
trimmed = True
start_off, end_off, pad_left, pad_right = calc_trim_text(text, idx, nl_pos, 0, width - ellipsis_width)
# pad_left should be 0, because the start_col parameter was 0 (no trimming on the left)
# similarly spos should not be changed from p
if pad_left != 0:
raise ValueError(f"Invalid padding for start column==0: {pad_left!r}")
if start_off != idx:
raise ValueError(f"Invalid start offset for start column==0 and position={idx!r}: {start_off!r}")
screen_columns = width - 1 - pad_right
else:
trimmed = False
end_off = nl_pos
pad_right = 0
line = []
if idx != end_off:
line += [(screen_columns, idx, end_off)]
if trimmed:
line += [(ellipsis_width, end_off, ellipsis_char)]
line += [(pad_right, end_off)]
segments.append(line)
idx = nl_pos + 1
return segments
def calculate_text_segments(
self,
text: str | bytes,
width: int,
wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,
) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:
"""
Calculate the segments of text to display given width screen columns to display them.
text - unicode text or byte string to display
width - number of available screen columns
wrap - wrapping mode used
Returns a layout structure without an alignment applied.
"""
if wrap in {"clip", "ellipsis"}:
return self._calculate_trimmed_segments(text, width, wrap)
nl, nl_o, sp_o = "\n", "\n", " "
if isinstance(text, bytes):
nl = b"\n" # can only find bytes in python3 bytestrings
nl_o = ord(nl_o) # + an item of a bytestring is the ordinal value
sp_o = ord(sp_o)
segments = []
idx = 0
while idx <= len(text):
# look for the next eligible line break
nl_pos = text.find(nl, idx)
if nl_pos == -1:
nl_pos = len(text)
screen_columns = calc_width(text, idx, nl_pos)
if screen_columns == 0:
# removed character hint
segments.append([(0, nl_pos)])
idx = nl_pos + 1
continue
if screen_columns <= width:
# this segment fits
segments.append([(screen_columns, idx, nl_pos), (0, nl_pos)])
# removed character hint
idx = nl_pos + 1
continue
pos, screen_columns = calc_text_pos(text, idx, nl_pos, width)
if pos == idx: # pathological width=1 double-byte case
raise CanNotDisplayText("Wide character will not fit in 1-column width")
if wrap == "any":
segments.append([(screen_columns, idx, pos)])
idx = pos
continue
if wrap != "space":
raise ValueError(wrap)
if text[pos] == sp_o:
# perfect space wrap
segments.append([(screen_columns, idx, pos), (0, pos)])
# removed character hint
idx = pos + 1
continue
if is_wide_char(text, pos):
# perfect next wide
segments.append([(screen_columns, idx, pos)])
idx = pos
continue
prev = pos
while prev > idx:
prev = move_prev_char(text, idx, prev)
if text[prev] == sp_o:
screen_columns = calc_width(text, idx, prev)
line = [(0, prev)]
if idx != prev:
line = [(screen_columns, idx, prev), *line]
segments.append(line)
idx = prev + 1
break
if is_wide_char(text, prev):
# wrap after wide char
next_char = move_next_char(text, prev, pos)
screen_columns = calc_width(text, idx, next_char)
segments.append([(screen_columns, idx, next_char)])
idx = next_char
break
else:
# unwrap previous line space if possible to
# fit more text (we're breaking a word anyway)
if segments and (len(segments[-1]) == 2 or (len(segments[-1]) == 1 and len(segments[-1][0]) == 2)):
# look for the removed space above
if len(segments[-1]) == 1:
[(h_sc, h_off)] = segments[-1]
p_sc = 0
p_off = _p_end = h_off
else:
[(p_sc, p_off, _p_end), (h_sc, h_off)] = segments[-1]
if p_sc < width and h_sc == 0 and text[h_off] == sp_o:
# combine with the previous line
del segments[-1]
idx = p_off
pos, screen_columns = calc_text_pos(text, idx, nl_pos, width)
segments.append([(screen_columns, idx, pos)])
# check for trailing " " or "\n"
idx = pos
if idx < len(text) and (text[idx] in {sp_o, nl_o}):
# removed character hint
segments[-1].append((0, idx))
idx += 1
continue
# force any char wrap
segments.append([(screen_columns, idx, pos)])
idx = pos
return segments
######################################
# default layout object to use
default_layout = StandardTextLayout()
######################################
class LayoutSegment:
def __init__(self, seg: tuple[int, int, int | bytes] | tuple[int, int | None]) -> None:
"""Create object from line layout segment structure"""
if not isinstance(seg, tuple):
raise TypeError(seg)
if len(seg) not in {2, 3}:
raise ValueError(seg)
self.sc, self.offs = seg[:2]
if not isinstance(self.sc, int):
raise TypeError(self.sc)
if len(seg) == 3:
if not isinstance(self.offs, int):
raise TypeError(self.offs)
if self.sc <= 0:
raise ValueError(seg)
t = seg[2]
if isinstance(t, bytes):
self.text: bytes | None = t
self.end = None
else:
if not isinstance(t, int):
raise TypeError(t)
self.text = None
self.end = t
else:
if len(seg) != 2:
raise ValueError(seg)
if self.offs is not None:
if self.sc < 0:
raise ValueError(seg)
if not isinstance(self.offs, int):
raise TypeError(self.offs)
self.text = self.end = None
def subseg(self, text: str | bytes, start: int, end: int) -> list[tuple[int, int] | tuple[int, int, int | bytes]]:
"""
Return a "sub-segment" list containing segment structures
that make up a portion of this segment.
A list is returned to handle cases where wide characters
need to be replaced with a space character at either edge
so two or three segments will be returned.
"""
start = max(start, 0)
end = min(end, self.sc)
if start >= end:
return [] # completely gone
if self.text:
# use text stored in segment (self.text)
spos, epos, pad_left, pad_right = calc_trim_text(self.text, 0, len(self.text), start, end)
return [(end - start, self.offs, b"".ljust(pad_left) + self.text[spos:epos] + b"".ljust(pad_right))]
if self.end:
# use text passed as parameter (text)
spos, epos, pad_left, pad_right = calc_trim_text(text, self.offs, self.end, start, end)
lines = []
if pad_left:
lines.append((1, spos - 1))
lines.append((end - start - pad_left - pad_right, spos, epos))
if pad_right:
lines.append((1, epos))
return lines
return [(end - start, self.offs)]
def line_width(segs: list[tuple[int, int, int | bytes] | tuple[int, int | None]]) -> int:
"""
Return the screen column width of one line of a text layout structure.
This function ignores any existing shift applied to the line,
represented by an (amount, None) tuple at the start of the line.
"""
sc = 0
seglist = segs
if segs and len(segs[0]) == 2 and segs[0][1] is None:
seglist = segs[1:]
for s in seglist:
sc += s[0]
return sc
def shift_line(
segs: list[tuple[int, int, int | bytes] | tuple[int, int | None]],
amount: int,
) -> list[tuple[int, int, int | bytes] | tuple[int, int | None]]:
"""
Return a shifted line from a layout structure to the left or right.
segs -- line of a layout structure
amount -- screen columns to shift right (+ve) or left (-ve)
"""
if not isinstance(amount, int):
raise TypeError(amount)
if segs and len(segs[0]) == 2 and segs[0][1] is None:
# existing shift
amount += segs[0][0]
if amount:
return [(amount, None), *segs[1:]]
return segs[1:]
if amount:
return [(amount, None), *segs]
return segs
def trim_line(
segs: list[tuple[int, int, int | bytes] | tuple[int, int | None]],
text: str | bytes,
start: int,
end: int,
) -> list[tuple[int, int, int | bytes] | tuple[int, int | None]]:
"""
Return a trimmed line of a text layout structure.
text -- text to which this layout structure applies
start -- starting screen column
end -- ending screen column
"""
result = []
x = 0
for seg in segs:
sc = seg[0]
if start or sc < 0:
if start >= sc:
start -= sc
x += sc
continue
s = LayoutSegment(seg)
if x + sc >= end:
# can all be done at once
return s.subseg(text, start, end - x)
result += s.subseg(text, start, sc)
start = 0
x += sc
continue
if x >= end:
break
if x + sc > end:
s = LayoutSegment(seg)
result += s.subseg(text, 0, end - x)
break
result.append(seg)
return result
def calc_line_pos(
text: str | bytes,
line_layout,
pref_col: Literal["left", "right", Align.LEFT, Align.RIGHT] | int,
):
"""
Calculate the closest linear position to pref_col given a
line layout structure. Returns None if no position found.
"""
closest_sc = None
closest_pos = None
current_sc = 0
if pref_col == "left":
for seg in line_layout:
s = LayoutSegment(seg)
if s.offs is not None:
return s.offs
return None
if pref_col == "right":
for seg in line_layout:
s = LayoutSegment(seg)
if s.offs is not None:
closest_pos = s
s = closest_pos
if s is None:
return None
if s.end is None:
return s.offs
return calc_text_pos(text, s.offs, s.end, s.sc - 1)[0]
for seg in line_layout:
s = LayoutSegment(seg)
if s.offs is not None:
if s.end is not None:
if current_sc <= pref_col < current_sc + s.sc:
# exact match within this segment
return calc_text_pos(text, s.offs, s.end, pref_col - current_sc)[0]
if current_sc <= pref_col:
closest_sc = current_sc + s.sc - 1
closest_pos = s
if closest_sc is None or (abs(pref_col - current_sc) < abs(pref_col - closest_sc)):
# this screen column is closer
closest_sc = current_sc
closest_pos = s.offs
if current_sc > closest_sc:
# we're moving past
break
current_sc += s.sc
if closest_pos is None or isinstance(closest_pos, int):
return closest_pos
# return the last positions in the segment "closest_pos"
s = closest_pos
return calc_text_pos(text, s.offs, s.end, s.sc - 1)[0]
def calc_pos(
text: str | bytes,
layout: list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]],
pref_col: Literal["left", "right", Align.LEFT, Align.RIGHT] | int,
row: int,
) -> int:
"""
Calculate the closest linear position to pref_col and row given a
layout structure.
"""
if row < 0 or row >= len(layout):
raise ValueError("calculate_pos: out of layout row range")
if (pos := calc_line_pos(text, layout[row], pref_col)) is not None:
return pos
rows_above = list(range(row - 1, -1, -1))
rows_below = list(range(row + 1, len(layout)))
while rows_above and rows_below:
if rows_above:
r = rows_above.pop(0)
if (pos := calc_line_pos(text, layout[r], pref_col)) is not None:
return pos
if rows_below:
r = rows_below.pop(0)
if (pos := calc_line_pos(text, layout[r], pref_col)) is not None:
return pos
return 0
def calc_coords(
text: str | bytes,
layout: list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]],
pos: int,
clamp: int = 1,
) -> tuple[int, int]:
"""
Calculate the coordinates closest to position pos in text with layout.
text -- raw string or unicode string
layout -- layout structure applied to text
pos -- integer position into text
clamp -- ignored right now
"""
closest: tuple[int, tuple[int, int]] | None = None
y = 0
for line_layout in layout:
x = 0
for seg in line_layout:
s = LayoutSegment(seg)
if s.offs is None:
x += s.sc
continue
if s.offs == pos:
return x, y
if s.end is not None and s.offs <= pos < s.end:
x += calc_width(text, s.offs, pos)
return x, y
distance = abs(s.offs - pos)
if s.end is not None and s.end < pos:
distance = pos - (s.end - 1)
if closest is None or distance < closest[0]: # pylint: disable=unsubscriptable-object
closest = distance, (x, y)
x += s.sc
y += 1
if closest:
return closest[1]
return 0, 0
@@ -0,0 +1,546 @@
# Urwid utility functions
# Copyright (C) 2004-2011 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
import codecs
import contextlib
import sys
import typing
import warnings
from contextlib import suppress
from urwid import str_util
if typing.TYPE_CHECKING:
from collections.abc import Generator, Iterable
from types import TracebackType
from typing_extensions import Literal, Protocol, Self
class CanBeStopped(Protocol):
def stop(self) -> None: ...
def __getattr__(name: str) -> typing.Any:
if hasattr(str_util, name):
warnings.warn(
f"Do not import {name!r} from {__package__}.{__name__}, import it from 'urwid'.",
DeprecationWarning,
stacklevel=3,
)
return getattr(str_util, name)
raise AttributeError(f"{name} is not defined in {__package__}.{__name__}")
def detect_encoding() -> str:
# Windows is a special case:
# CMD is Unicode and non-unicode at the same time:
# Unicode display support depends on C API usage and partially limited by font settings.
# By default, python is distributed in "Unicode" version, and since Python version 3.8 only Unicode.
# In case of curses, "windows-curses" is distributed in unicode-only.
# Since Windows 10 default console font is already unicode,
# in older versions maybe need to set TTF font manually to support more symbols
# (this does not affect unicode IO).
if sys.platform == "win32" and sys.getdefaultencoding() == "utf-8":
return "utf-8"
# Try to determine if using a supported double-byte encoding
import locale
no_set_locale = locale.getpreferredencoding(False)
if no_set_locale != "ascii":
# ascii is fallback locale in case of detect failed
return no_set_locale
# Use actual `getpreferredencoding` with public API only
old_loc = locale.setlocale(locale.LC_CTYPE) # == getlocale, but not mangle data
try:
with suppress(locale.Error):
locale.setlocale(locale.LC_CTYPE, "")
# internally call private `_get_locale_encoding`
return locale.getpreferredencoding(False)
finally:
with suppress(locale.Error):
locale.setlocale(locale.LC_CTYPE, old_loc)
if "detected_encoding" not in locals():
detected_encoding = detect_encoding()
else:
raise RuntimeError("Encoding detection broken")
_target_encoding = "ascii"
_use_dec_special = True
def set_encoding(encoding: str) -> None:
"""
Set the byte encoding to assume when processing strings and the
encoding to use when converting unicode strings.
"""
encoding = encoding.lower()
global _target_encoding, _use_dec_special # noqa: PLW0603 # noqa: PLW0603 # pylint: disable=global-statement
if encoding in {"utf-8", "utf8", "utf"}:
str_util.set_byte_encoding("utf8")
_use_dec_special = False
elif encoding in {
"euc-jp", # JISX 0208 only
"euc-kr",
"euc-cn",
"euc-tw", # CNS 11643 plain 1 only
"gb2312",
"gbk",
"big5",
"cn-gb",
"uhc",
# these shouldn't happen, should they?
"eucjp",
"euckr",
"euccn",
"euctw",
"cncb",
}:
str_util.set_byte_encoding("wide")
_use_dec_special = True
else:
str_util.set_byte_encoding("narrow")
_use_dec_special = True
# if encoding is valid for conversion from unicode, remember it
_target_encoding = "ascii"
with contextlib.suppress(LookupError):
if encoding:
"".encode(encoding)
_target_encoding = encoding
def get_encoding() -> str:
"""Get target encoding."""
return _target_encoding
@contextlib.contextmanager
def set_temporary_encoding(encoding_name: str) -> Generator[None]:
"""Internal helper for encoding specific validation in unittests/doctests.
Not exported globally.
"""
old_encoding = _target_encoding
try:
set_encoding(encoding_name)
yield
finally:
set_encoding(old_encoding)
def get_encoding_mode() -> Literal["wide", "narrow", "utf8"]:
"""
Get the mode Urwid is using when processing text strings.
Returns 'narrow' for 8-bit encodings, 'wide' for CJK encodings
or 'utf8' for UTF-8 encodings.
"""
return str_util.get_byte_encoding()
def apply_target_encoding(s: str | bytes) -> tuple[bytes, list[tuple[Literal["U", "0"] | None, int]]]:
"""
Return (encoded byte string, character set rle).
"""
# Import locally to warranty no circular imports
from urwid.display import escape
if _use_dec_special and isinstance(s, str):
# first convert drawing characters
s = s.translate(escape.DEC_SPECIAL_CHARMAP)
if isinstance(s, str):
s = s.replace(escape.SI + escape.SO, "") # remove redundant shifts
s = codecs.encode(s, _target_encoding, "replace")
if not isinstance(s, bytes):
raise TypeError(s)
SO = escape.SO.encode("ascii")
SI = escape.SI.encode("ascii")
sis = s.split(SO)
sis0 = sis[0].replace(SI, b"")
sout = []
cout = []
if sis0:
sout.append(sis0)
cout.append((None, len(sis0)))
if len(sis) == 1:
return sis0, cout
for sn in sis[1:]:
sl = sn.split(SI, 1)
if len(sl) == 1:
sin = sl[0]
sout.append(sin)
rle_append_modify(cout, (escape.DEC_TAG, len(sin)))
continue
sin, son = sl
son = son.replace(SI, b"")
if sin:
sout.append(sin)
rle_append_modify(cout, (escape.DEC_TAG, len(sin)))
if son:
sout.append(son)
rle_append_modify(cout, (None, len(son)))
outstr = b"".join(sout)
return outstr, cout
######################################################################
# Try to set the encoding using the one detected by the locale module
set_encoding(detected_encoding)
######################################################################
def supports_unicode() -> bool:
"""
Return True if python is able to convert non-ascii unicode strings
to the current encoding.
"""
return _target_encoding and _target_encoding != "ascii"
def calc_trim_text(
text: str | bytes,
start_offs: int,
end_offs: int,
start_col: int,
end_col: int,
) -> tuple[int, int, int, int]:
"""
Calculate the result of trimming text.
start_offs -- offset into text to treat as screen column 0
end_offs -- offset into text to treat as the end of the line
start_col -- screen column to trim at the left
end_col -- screen column to trim at the right
Returns (start, end, pad_left, pad_right), where:
start -- resulting start offset
end -- resulting end offset
pad_left -- 0 for no pad or 1 for one space to be added
pad_right -- 0 for no pad or 1 for one space to be added
"""
spos = start_offs
pad_left = pad_right = 0
if start_col > 0:
spos, sc = str_util.calc_text_pos(text, spos, end_offs, start_col)
if sc < start_col:
pad_left = 1
spos, sc = str_util.calc_text_pos(text, start_offs, end_offs, start_col + 1)
run = end_col - start_col - pad_left
pos, sc = str_util.calc_text_pos(text, spos, end_offs, run)
if sc < run:
pad_right = 1
return (spos, pos, pad_left, pad_right)
def trim_text_attr_cs(text: bytes, attr, cs, start_col: int, end_col: int):
"""
Return ( trimmed text, trimmed attr, trimmed cs ).
"""
spos, epos, pad_left, pad_right = calc_trim_text(text, 0, len(text), start_col, end_col)
attrtr = rle_subseg(attr, spos, epos)
cstr = rle_subseg(cs, spos, epos)
if pad_left:
al = rle_get_at(attr, spos - 1)
rle_prepend_modify(attrtr, (al, 1))
rle_prepend_modify(cstr, (None, 1))
if pad_right:
al = rle_get_at(attr, epos)
rle_append_modify(attrtr, (al, 1))
rle_append_modify(cstr, (None, 1))
return (b"".rjust(pad_left) + text[spos:epos] + b"".rjust(pad_right), attrtr, cstr)
def rle_get_at(rle, pos: int):
"""
Return the attribute at offset pos.
"""
x = 0
if pos < 0:
return None
for a, run in rle:
if x + run > pos:
return a
x += run
return None
def rle_subseg(rle, start: int, end: int):
"""Return a sub segment of a rle list."""
sub_segment = []
x = 0
for a, run in rle:
if start:
if start >= run:
start -= run
x += run
continue
x += start
run -= start # noqa: PLW2901
start = 0
if x >= end:
break
if x + run > end:
run = end - x # noqa: PLW2901
x += run
sub_segment.append((a, run))
return sub_segment
def rle_len(rle: Iterable[tuple[typing.Any, int]]) -> int:
"""
Return the number of characters covered by a run length
encoded attribute list.
"""
run = 0
for v in rle:
if not isinstance(v, tuple):
raise TypeError(rle)
_a, r = v
run += r
return run
def rle_prepend_modify(rle, a_r) -> None:
"""
Append (a, r) (unpacked from *a_r*) to BEGINNING of rle.
Merge with first run when possible
MODIFIES rle parameter contents. Returns None.
"""
a, r = a_r
if not rle:
rle[:] = [(a, r)]
else:
al, run = rle[0]
if a == al:
rle[0] = (a, run + r)
else:
rle[0:0] = [(a, r)]
def rle_append_modify(rle, a_r) -> None:
"""
Append (a, r) (unpacked from *a_r*) to the rle list rle.
Merge with last run when possible.
MODIFIES rle parameter contents. Returns None.
"""
a, r = a_r
if not rle or rle[-1][0] != a:
rle.append((a, r))
return
_la, lr = rle[-1]
rle[-1] = (a, lr + r)
def rle_join_modify(rle, rle2) -> None:
"""
Append attribute list rle2 to rle.
Merge last run of rle with first run of rle2 when possible.
MODIFIES attr parameter contents. Returns None.
"""
if not rle2:
return
rle_append_modify(rle, rle2[0])
rle += rle2[1:]
def rle_product(rle1, rle2):
"""
Merge the runs of rle1 and rle2 like this:
eg.
rle1 = [ ("a", 10), ("b", 5) ]
rle2 = [ ("Q", 5), ("P", 10) ]
rle_product: [ (("a","Q"), 5), (("a","P"), 5), (("b","P"), 5) ]
rle1 and rle2 are assumed to cover the same total run.
"""
i1 = i2 = 1 # rle1, rle2 indexes
if not rle1 or not rle2:
return []
a1, r1 = rle1[0]
a2, r2 = rle2[0]
result = []
while r1 and r2:
r = min(r1, r2)
rle_append_modify(result, ((a1, a2), r))
r1 -= r
if r1 == 0 and i1 < len(rle1):
a1, r1 = rle1[i1]
i1 += 1
r2 -= r
if r2 == 0 and i2 < len(rle2):
a2, r2 = rle2[i2]
i2 += 1
return result
def rle_factor(rle):
"""
Inverse of rle_product.
"""
rle1 = []
rle2 = []
for (a1, a2), r in rle:
rle_append_modify(rle1, (a1, r))
rle_append_modify(rle2, (a2, r))
return rle1, rle2
class TagMarkupException(Exception):
pass
def decompose_tagmarkup(tm):
"""Return (text string, attribute list) for tagmarkup passed."""
tl, al = _tagmarkup_recurse(tm, None)
# join as unicode or bytes based on type of first element
if tl:
text = tl[0][:0].join(tl)
else:
text = ""
if al and al[-1][0] is None:
del al[-1]
return text, al
def _tagmarkup_recurse(tm, attr):
"""Return (text list, attribute list) for tagmarkup passed.
tm -- tagmarkup
attr -- current attribute or None"""
if isinstance(tm, list):
# for lists recurse to process each subelement
rtl = []
ral = []
for element in tm:
tl, al = _tagmarkup_recurse(element, attr)
if ral:
# merge attributes when possible
last_attr, last_run = ral[-1]
top_attr, top_run = al[0]
if last_attr == top_attr:
ral[-1] = (top_attr, last_run + top_run)
del al[0]
rtl += tl
ral += al
return rtl, ral
if isinstance(tm, tuple):
# tuples mark a new attribute boundary
if len(tm) != 2:
raise TagMarkupException(f"Tuples must be in the form (attribute, tagmarkup): {tm!r}")
attr, element = tm
return _tagmarkup_recurse(element, attr)
if not isinstance(tm, (str, bytes)):
raise TagMarkupException(f"Invalid markup element: {tm!r}")
# text
return [tm], [(attr, len(tm))]
def is_mouse_event(ev: tuple[str, int, int, int] | typing.Any) -> bool:
return isinstance(ev, tuple) and len(ev) == 4 and "mouse" in ev[0]
def is_mouse_press(ev: str) -> bool:
return "press" in ev
class MetaSuper(type):
"""Deprecated metaclass.
Present only for code compatibility, all logic has been removed.
Please move to the last position in the class bases to allow future changes.
"""
__slots__ = ()
def int_scale(val: int, val_range: int, out_range: int) -> int:
"""
Scale val in the range [0, val_range-1] to an integer in the range
[0, out_range-1]. This implementation uses the "round-half-up" rounding
method.
>>> "%x" % int_scale(0x7, 0x10, 0x10000)
'7777'
>>> "%x" % int_scale(0x5f, 0x100, 0x10)
'6'
>>> int_scale(2, 6, 101)
40
>>> int_scale(1, 3, 4)
2
"""
num = int(val * (out_range - 1) * 2 + (val_range - 1))
dem = (val_range - 1) * 2
# if num % dem == 0 then we are exactly half-way and have rounded up.
return num // dem
class StoppingContext(contextlib.AbstractContextManager["StoppingContext"]):
"""Context manager that calls ``stop`` on a given object on exit. Used to
make the ``start`` method on `MainLoop` and `BaseScreen` optionally act as
context managers.
"""
__slots__ = ("_wrapped",)
def __init__(self, wrapped: CanBeStopped) -> None:
self._wrapped = wrapped
def __enter__(self) -> Self:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self._wrapped.stop()
@@ -0,0 +1,34 @@
# file generated by setuptools-scm
# don't change, don't track in version control
__all__ = [
"__version__",
"__version_tuple__",
"version",
"version_tuple",
"__commit_id__",
"commit_id",
]
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import Tuple
from typing import Union
VERSION_TUPLE = Tuple[Union[int, str], ...]
COMMIT_ID = Union[str, None]
else:
VERSION_TUPLE = object
COMMIT_ID = object
version: str
__version__: str
__version_tuple__: VERSION_TUPLE
version_tuple: VERSION_TUPLE
commit_id: COMMIT_ID
__commit_id__: COMMIT_ID
__version__ = version = '3.0.3'
__version_tuple__ = version_tuple = (3, 0, 3)
__commit_id__ = commit_id = None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,193 @@
from __future__ import annotations
from .attr_map import AttrMap, AttrMapError
from .attr_wrap import AttrWrap
from .bar_graph import BarGraph, BarGraphError, BarGraphMeta, GraphVScale, scale_bar_values
from .big_text import BigText
from .box_adapter import BoxAdapter, BoxAdapterError
from .columns import Columns, ColumnsError, ColumnsWarning
from .constants import (
RELATIVE_100,
Align,
Sizing,
VAlign,
WHSettings,
WrapMode,
normalize_align,
normalize_height,
normalize_valign,
normalize_width,
simplify_align,
simplify_height,
simplify_valign,
simplify_width,
)
from .container import WidgetContainerListContentsMixin, WidgetContainerMixin
from .divider import Divider
from .edit import Edit, EditError, IntEdit
from .filler import Filler, FillerError, calculate_top_bottom_filler
from .frame import Frame, FrameError
from .grid_flow import GridFlow, GridFlowError, GridFlowWarning
from .line_box import LineBox
from .listbox import ListBox, ListBoxError, ListWalker, ListWalkerError, SimpleFocusListWalker, SimpleListWalker
from .monitored_list import MonitoredFocusList, MonitoredList
from .overlay import Overlay, OverlayError, OverlayWarning
from .padding import Padding, PaddingError, PaddingWarning, calculate_left_right_padding
from .pile import Pile, PileError, PileWarning
from .popup import PopUpLauncher, PopUpTarget
from .progress_bar import ProgressBar
from .scrollable import Scrollable, ScrollableError, ScrollBar
from .solid_fill import SolidFill
from .text import Text, TextError
from .treetools import ParentNode, TreeListBox, TreeNode, TreeWalker, TreeWidget, TreeWidgetError
from .widget import (
Widget,
WidgetError,
WidgetMeta,
WidgetWarning,
WidgetWrap,
WidgetWrapError,
delegate_to_widget_mixin,
fixed_size,
nocache_widget_render,
nocache_widget_render_instance,
)
from .widget_decoration import WidgetDecoration, WidgetDisable, WidgetPlaceholder
from .wimp import Button, CheckBox, CheckBoxError, RadioButton, SelectableIcon
__all__ = (
"ANY",
"BOTTOM",
"BOX",
"CENTER",
"CLIP",
"ELLIPSIS",
"FIXED",
"FLOW",
"GIVEN",
"LEFT",
"MIDDLE",
"PACK",
"RELATIVE",
"RELATIVE_100",
"RIGHT",
"SPACE",
"TOP",
"WEIGHT",
"Align",
"AttrMap",
"AttrMapError",
"AttrWrap",
"BarGraph",
"BarGraphError",
"BarGraphMeta",
"BigText",
"BoxAdapter",
"BoxAdapterError",
"Button",
"CheckBox",
"CheckBoxError",
"Columns",
"ColumnsError",
"ColumnsWarning",
"Divider",
"Edit",
"EditError",
"Filler",
"FillerError",
"Frame",
"FrameError",
"GraphVScale",
"GridFlow",
"GridFlowError",
"GridFlowWarning",
"IntEdit",
"LineBox",
"ListBox",
"ListBoxError",
"ListWalker",
"ListWalkerError",
"MonitoredFocusList",
"MonitoredList",
"Overlay",
"OverlayError",
"OverlayWarning",
"Padding",
"PaddingError",
"PaddingWarning",
"ParentNode",
"Pile",
"PileError",
"PileWarning",
"PopUpLauncher",
"PopUpTarget",
"ProgressBar",
"RadioButton",
"ScrollBar",
"Scrollable",
"ScrollableError",
"SelectableIcon",
"SimpleFocusListWalker",
"SimpleListWalker",
"Sizing",
"SolidFill",
"Text",
"TextError",
"TreeListBox",
"TreeNode",
"TreeWalker",
"TreeWidget",
"TreeWidgetError",
"VAlign",
"WHSettings",
"Widget",
"WidgetContainerListContentsMixin",
"WidgetContainerMixin",
"WidgetDecoration",
"WidgetDisable",
"WidgetError",
"WidgetMeta",
"WidgetPlaceholder",
"WidgetWarning",
"WidgetWrap",
"WidgetWrapError",
"WrapMode",
"calculate_left_right_padding",
"calculate_top_bottom_filler",
"delegate_to_widget_mixin",
"fixed_size",
"nocache_widget_render",
"nocache_widget_render_instance",
"normalize_align",
"normalize_height",
"normalize_valign",
"normalize_width",
"scale_bar_values",
"simplify_align",
"simplify_height",
"simplify_valign",
"simplify_width",
)
# Backward compatibility
FLOW = Sizing.FLOW
BOX = Sizing.BOX
FIXED = Sizing.FIXED
LEFT = Align.LEFT
RIGHT = Align.RIGHT
CENTER = Align.CENTER
TOP = VAlign.TOP
MIDDLE = VAlign.MIDDLE
BOTTOM = VAlign.BOTTOM
SPACE = WrapMode.SPACE
ANY = WrapMode.ANY
CLIP = WrapMode.CLIP
ELLIPSIS = WrapMode.ELLIPSIS
PACK = WHSettings.PACK
GIVEN = WHSettings.GIVEN
RELATIVE = WHSettings.RELATIVE
WEIGHT = WHSettings.WEIGHT

Some files were not shown because too many files have changed in this diff Show More