Update documentation with overview and architecture
This commit is contained in:
@@ -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__}")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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__}")
|
||||
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user