change name to textual

This commit is contained in:
Will McGugan
2021-06-02 21:20:14 +01:00
parent 490462783b
commit f41cbfd1b7
23 changed files with 200 additions and 101 deletions

8
poetry.lock generated
View File

@@ -35,10 +35,10 @@ python-versions = "^3.6"
develop = false
[package.dependencies]
typing-extensions = {version = "^3.7.4", markers = "python_version < \"3.8\""}
pygments = "^2.6.0"
colorama = "^0.4.0"
commonmark = "^0.9.0"
pygments = "^2.6.0"
typing-extensions = {version = "^3.7.4", markers = "python_version < \"3.8\""}
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"]
@@ -47,7 +47,7 @@ jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"]
type = "git"
url = "git@github.com:willmcgugan/rich"
reference = "tui"
resolved_reference = "34630b02b60c1c1cce4b072410cd93f6a6385039"
resolved_reference = "803651999e50ac2df1a60f1c0dd85729d7b556ce"
[[package]]
name = "typing-extensions"
@@ -60,7 +60,7 @@ python-versions = "*"
[metadata]
lock-version = "1.1"
python-versions = "^3.7"
content-hash = "b5400274e471bd51f46cb352194316d479f45e3affa0d9aba926a7bbf2e0c551"
content-hash = "8557cc8fc8af15c01c0f20b519b680fbb32f35233a0065137d288dfdf67af3f1"
[metadata.files]
colorama = [

View File

@@ -1,15 +1,13 @@
[tool.poetry]
name = "rich.tui"
name = "textual"
version = "0.1.0"
description = "Build Text User Interfaces with Rich"
description = "Rich TUI"
authors = ["Will McGugan <willmcgugan@gmail.com>"]
license = "MIT"
packages = [
{ include = "rich" }
]
[tool.poetry.dependencies]
python = "^3.7"
rich = "^10.2.2"
[tool.poetry.dev-dependencies]
rich = {git = "git@github.com:willmcgugan/rich", rev = "tui"}

View File

@@ -3,5 +3,8 @@
{
"path": "."
}
]
],
"settings": {
"python.pythonPath": "/Users/willmcgugan/Venvs/textual/bin/python"
}
}

View File

@@ -1,34 +0,0 @@
from time import time
from typing import ClassVar
from enum import auto, Enum
from .case import camel_to_snake
class ActionType(Enum):
CUSTOM = auto()
QUIT = auto()
class Action:
type: ClassVar[ActionType]
def __init__(self) -> None:
self.time = time()
def __init_subclass__(cls, type: ActionType) -> None:
super().__init_subclass__()
cls.type = type
@property
def name(self) -> str:
if not hasattr(self, "_name"):
_name = camel_to_snake(self.__class__.__name__)
if _name.endswith("_event"):
_name = _name[:-6]
self._name = _name
return self._name
class QuitAction(Action, type=ActionType.QUIT):
pass

View File

@@ -7,7 +7,7 @@ import weakref
from rich.repr import rich_repr, RichReprResult
from . import events
from .types import MessageTarget
from ._types import MessageTarget
TimerCallback = Callable[[], Awaitable[None]]

View File

@@ -26,4 +26,4 @@ class EventTarget(Protocol):
...
MessageHandler = Callable[["Message"], Awaitable]
MessageHandler = Callable[["Message"], Awaitable]

View File

@@ -2,16 +2,16 @@ import asyncio
import logging
import signal
from typing import Any, Dict, Set
from typing import Any, ClassVar, Dict, Set, List
from rich.control import Control
from rich.repr import rich_repr, RichReprResult
from rich.screen import Screen
from rich import get_console
from rich.console import Console
from . import events
from ._context import active_app
from .. import get_console
from ..console import Console
from .driver import Driver, CursesDriver
from .message_pump import MessagePump
from .view import View, LayoutView
@@ -26,6 +26,8 @@ LayoutDefinition = Dict[str, Any]
class App(MessagePump):
view: View
KEYS: ClassVar[Dict[str, str]] = {}
def __init__(
self,
console: Console = None,
@@ -88,8 +90,28 @@ class App(MessagePump):
Screen(Control.home(), self.view, Control.home(), application_mode=True)
)
async def on_startup(self, event: events.Startup) -> None:
pass
async def action(self, action: str) -> None:
if "." in action:
destination, action_name, *tokens = action.split(".")
else:
destination = "app"
action_name = action
tokens = []
if destination == "app":
method_name = f"action_{action_name}"
method = getattr(self, method_name, None)
if method is not None:
await method(tokens)
async def on_key(self, event: events.Key) -> None:
key_action = self.KEYS.get(event.key, None)
if key_action is not None:
log.debug("action %r", key_action)
await self.action(key_action)
# if event.key == "q":
# await self.close_messages()
async def on_shutdown_request(self, event: events.ShutdownRequest) -> None:
await self.close_messages()
@@ -102,6 +124,12 @@ class App(MessagePump):
async def on_mouse_move(self, event: events.MouseMove) -> None:
await self.view.post_message(event)
async def on_mouse_clicked(self, event: events.MouseClicked) -> None:
await self.view.post_message(event)
async def action_quit(self, tokens: List[str]) -> None:
await self.close_messages()
if __name__ == "__main__":
import asyncio
@@ -118,14 +146,10 @@ if __name__ == "__main__":
)
class MyApp(App):
async def on_key(self, event: events.Key) -> None:
log.debug("on_key %r", event)
if event.key == "q":
await self.close_messages()
KEYS = {"q": "quit"}
async def on_startup(self, event: events.Startup) -> None:
await self.view.mount(Header(self.title), slot="header")
await self.view.mount(Placeholder(), slot="body")
self.refresh()
await self.view.mount_all(header=Header(self.title), body=Placeholder())
MyApp.run()

View File

@@ -11,7 +11,7 @@ from threading import Event, Thread
from typing import Optional, Tuple, TYPE_CHECKING
from . import events
from .types import MessageTarget
from ._types import MessageTarget
if TYPE_CHECKING:
from ..console import Console

View File

@@ -4,10 +4,10 @@ from enum import auto, Enum
from time import monotonic
from typing import ClassVar, Optional, Set, TYPE_CHECKING
from ..repr import rich_repr, RichReprResult
from rich.repr import rich_repr, RichReprResult
from .message import Message
from .types import Callback, MessageTarget
from ._types import Callback, MessageTarget
if TYPE_CHECKING:
@@ -28,7 +28,7 @@ class EventType(Enum):
SHUTDOWN_REQUEST = auto()
SHUTDOWN = auto()
EXIT = auto()
REFRESH = auto()
UPDATED = auto()
TIMER = auto()
FOCUS = auto()
BLUR = auto()
@@ -54,6 +54,7 @@ class Event(Message):
def __init_subclass__(
cls, type: EventType, priority: int = 0, bubble: bool = False
) -> None:
cls.type = type
super().__init_subclass__(priority=priority, bubble=bubble)
def __enter__(self) -> "Event":
@@ -81,6 +82,10 @@ class Created(Event, type=EventType.CREATED):
pass
class Updated(Event, type=EventType.UPDATED):
"""Indicates the sender was updated and needs a refresh."""
class Idle(Event, type=EventType.IDLE):
"""Sent when there are no more items in the message queue."""
@@ -111,10 +116,6 @@ class Shutdown(Event, type=EventType.SHUTDOWN):
pass
class Refresh(Event, type=EventType.REFRESH):
pass
@rich_repr
class Key(Event, type=EventType.KEY, bubble=True):
code: int = 0
@@ -145,7 +146,7 @@ class MouseMove(Event, type=EventType.MOUSE_MOVE):
@rich_repr
class MousePressed(Event, type=EventType.MOUSE_PRESSED):
class MouseBase(Event, type=EventType.MOUSE_PRESSED):
def __init__(
self,
sender: MessageTarget,
@@ -173,15 +174,19 @@ class MousePressed(Event, type=EventType.MOUSE_PRESSED):
yield "shift", self.shift, False
class MouseReleased(MousePressed, type=EventType.MOUSE_RELEASED):
class MousePressed(MouseBase, type=EventType.MOUSE_MOVE):
pass
class MouseClicked(MousePressed, type=EventType.MOUSE_CLICKED):
class MouseReleased(MouseBase, type=EventType.MOUSE_RELEASED):
pass
class MouseDoubleClicked(MousePressed, type=EventType.MOUSE_DOUBLE_CLICKED):
class MouseClicked(MouseBase, type=EventType.MOUSE_CLICKED):
pass
class MouseDoubleClicked(MouseBase, type=EventType.MOUSE_DOUBLE_CLICKED):
pass
@@ -225,4 +230,4 @@ class Focus(Event, type=EventType.FOCUS):
class Blur(Event, type=EventType.BLUR):
pass
pass

View File

@@ -3,7 +3,7 @@ from typing import ClassVar
from .case import camel_to_snake
from .types import MessageTarget
from ._types import MessageTarget
class Message:
@@ -31,4 +31,4 @@ class Message:
suppress (bool, optional): True if the default action should be suppressed,
or False if the default actions should be performed. Defaults to True.
"""
self.suppress = suppress
self.suppress = suppress

View File

@@ -1,15 +1,13 @@
from functools import total_ordering
from typing import AsyncIterable, Optional, NamedTuple, Set, Type, Tuple, TYPE_CHECKING
from typing import Optional, NamedTuple, Set, Type, TYPE_CHECKING
import asyncio
from asyncio import PriorityQueue
from functools import total_ordering
import logging
from . import events
from .message import Message
from ._timer import Timer, TimerCallback
from .types import MessageHandler
from ._types import MessageHandler
log = logging.getLogger("rich")
@@ -18,23 +16,29 @@ class MessageQueueItem(NamedTuple):
priority: int
message: Message
def __lt__(self, other: "MessageQueueItem") -> bool:
return self.priority < other.priority
def __lt__(self, other: object) -> bool:
other_priority = other.priority if isinstance(other, MessageQueueItem) else 0
return self.priority < other_priority
def __le__(self, other: "MessageQueueItem") -> bool:
return self.priority <= other.priority
def __le__(self, other: object) -> bool:
other_priority = other.priority if isinstance(other, MessageQueueItem) else 0
return self.priority <= other_priority
def __gt__(self, other: "MessageQueueItem") -> bool:
return self.priority > other.priority
def __gt__(self, other: object) -> bool:
other_priority = other.priority if isinstance(other, MessageQueueItem) else 0
return self.priority > other_priority
def __ge__(self, other: "MessageQueueItem") -> bool:
return self.priority >= other.priority
def __ge__(self, other: object) -> bool:
other_priority = other.priority if isinstance(other, MessageQueueItem) else 0
return self.priority >= other_priority
def __eq__(self, other: "MessageQueueItem") -> bool:
return self.priority == other.priority
def __eq__(self, other: object) -> bool:
other_priority = other.priority if isinstance(other, MessageQueueItem) else 0
return self.priority == other_priority
def __ne__(self, other: "MessageQueueItem") -> bool:
return self.priority != other.priority
def __ne__(self, other: object) -> bool:
other_priority = other.priority if isinstance(other, MessageQueueItem) else 0
return self.priority != other_priority
class MessagePumpClosed(Exception):
@@ -51,14 +55,14 @@ class MessagePump:
self._parent = parent
self._closing: bool = False
self._closed: bool = False
self._disabled_messages: Set[Message] = set()
self._disabled_messages: Set[Type[Message]] = set()
def check_message_enabled(self, message: Message) -> bool:
return type(message) not in self._disabled_messages
def disable_messages(self, *messages: Type[Message]) -> None:
"""Disable message types from being proccessed."""
self._disabled_messages.intersection_update(messages)
self._disabled_messages.update(messages)
def enable_messages(self, *messages: Type[Message]) -> None:
"""Enable processing of messages types."""
@@ -117,7 +121,6 @@ class MessagePump:
except Exception:
log.exception("error getting message")
break
log.debug("%r -> %r", message, self)
await self.dispatch_message(message, priority)
if self._message_queue.empty():
await self.dispatch_message(events.Idle(self))
@@ -137,7 +140,10 @@ class MessagePump:
if dispatch_function is not None:
await dispatch_function(event)
if event.bubble and self._parent:
await self._parent.post_message(event, priority)
if event.sender == self._parent:
log.debug("bubbled event abandoned; %r", event)
else:
await self._parent.post_message(event, priority)
async def on_message(self, message: Message) -> None:
pass

38
src/textual/state.py Normal file
View File

@@ -0,0 +1,38 @@
from typing import Generic, Type, TypeVar
ParentType = TypeVar("ParentType")
ValueType = TypeVar("ValueType")
class Reactive(Generic[ValueType]):
def __init__(self, default: ValueType) -> None:
self._default = default
def __set_name__(self, owner: object, name: str) -> None:
self.internal_name = f"_{name}"
setattr(owner, self.internal_name, self._default)
def __get__(self, obj: object, obj_type: Type[object]) -> ValueType:
print("__get__", obj, obj_type)
return getattr(obj, self.internal_name)
def __set__(self, obj: object, value: ValueType) -> None:
print("__set__", obj, value)
setattr(obj, self.internal_name, value)
class Example:
def __init__(self, foo: int = 3) -> None:
self.foo = foo
color: Reactive[str] = Reactive("blue")
example = Example()
print(example.color)
example.color = "red"
print(example.color)
print(example.foo)

View File

@@ -9,6 +9,7 @@ from rich.repr import rich_repr, RichReprResult
from . import events
from ._context import active_app
from .message import Message
from .message_pump import MessagePump
from .widget import Widget
from .widgets.header import Header
@@ -50,6 +51,10 @@ class View(ABC, MessagePump):
async def mount(self, widget: Widget, *, slot: str = "main") -> None:
...
async def mount_all(self, **widgets: Widget) -> None:
for slot, widget in widgets.items():
await self.mount(widget, slot=slot)
class LayoutView(View):
layout: Layout
@@ -76,6 +81,7 @@ class LayoutView(View):
)
self.layout = layout
self.mouse_over: Optional[MessagePump] = None
self.focused: Optional[MessagePump] = None
super().__init__()
def __rich_repr__(self) -> RichReprResult:
@@ -84,10 +90,10 @@ class LayoutView(View):
def __rich__(self) -> RenderableType:
return self.layout
def get_widget_at(self, x: int, y: int) -> Tuple[MessagePump, Region]:
def get_widget_at(self, x: int, y: int) -> Tuple[Widget, Region]:
for layout, (region, render) in self.layout.map.items():
if region.contains(x, y):
if isinstance(layout.renderable, MessagePump):
if isinstance(layout.renderable, Widget):
return layout.renderable, region
else:
break
@@ -101,6 +107,21 @@ class LayoutView(View):
await self.app.add(widget)
await widget.post_message(events.Mount(sender=self))
async def set_focus(self, widget: Optional[Widget]) -> None:
if widget == self.focused:
return
if widget is None:
if self.focused is not None:
focused = self.focused
self.focused = None
await focused.post_message(events.Blur(self))
elif widget.can_focus:
if self.focused is not None:
await self.focused.post_message(events.Blur(self))
if widget is not None and self.focused != widget:
self.focused = widget
await widget.post_message(events.Focus(self))
async def on_startup(self, event: events.Startup) -> None:
await self.mount(Header(self.title), slot="header")
@@ -129,3 +150,11 @@ class LayoutView(View):
await widget.post_message(
events.MouseMove(self, event.x - region.x, event.y - region.y)
)
async def on_mouse_clicked(self, event: events.MouseClicked) -> None:
try:
widget, _region = self.get_widget_at(event.x, event.y)
except NoWidget:
await self.set_focus(None)
else:
await self.set_focus(widget)

View File

@@ -11,6 +11,7 @@ from rich.repr import rich_repr, RichReprResult
from . import events
from ._context import active_app
from .message import Message
from .message_pump import MessagePump
if TYPE_CHECKING:
@@ -36,13 +37,18 @@ class Widget(MessagePump):
Widget._count += 1
self.size = WidgetDimensions(0, 0)
self.size_changed = False
self.mouse_over = False
self._has_focus = False
self._mouse_over = False
super().__init__()
if not self.mouse_events:
self.disable_messages(events.MouseMove)
if not self.idle_events:
self.disable_messages(events.Idle)
def __init_subclass__(cls, can_focus: bool = True) -> None:
super().__init_subclass__()
cls.can_focus = can_focus
@property
def app(self) -> "App":
return active_app.get()
@@ -51,6 +57,14 @@ class Widget(MessagePump):
def console(self) -> Console:
return active_app.get().console
@property
def has_focus(self) -> bool:
return self._has_focus
@property
def mouse_over(self) -> bool:
return self._mouse_over
async def refresh(self) -> None:
self.app.refresh()
@@ -64,7 +78,7 @@ class Widget(MessagePump):
Align.center(Pretty(self), vertical="middle"),
title=self.__class__.__name__,
border_style="green" if self.mouse_over else "blue",
box=box.HEAVY if self.mouse_over else box.ROUNDED,
box=box.HEAVY if self.has_focus else box.ROUNDED,
)
def __rich_console__(
@@ -75,9 +89,24 @@ class Widget(MessagePump):
self.size = new_size
yield renderable
async def post_message(
self, message: Message, priority: Optional[int] = None
) -> bool:
if not self.check_message_enabled(message):
return True
log.debug("%r -> %r", message, self)
return await super().post_message(message, priority)
async def on_event(self, event: events.Event, priority: int) -> None:
if isinstance(event, (events.MouseEnter, events.MouseLeave)):
self.mouse_over = isinstance(event, events.MouseEnter)
log.debug("%r", self.mouse_over)
self._mouse_over = isinstance(event, events.MouseEnter)
await self.refresh()
await super().on_event(event, priority)
async def on_focus(self, event: events.Focus) -> None:
self._has_focus = True
await self.refresh()
async def on_blur(self, event: events.Focus) -> None:
self._has_focus = False
await self.refresh()

View File

View File

@@ -3,7 +3,8 @@ from ..widget import Widget
from rich.repr import RichReprResult
class Placeholder(Widget):
class Placeholder(Widget, can_focus=True):
def __rich_repr__(self) -> RichReprResult:
yield "name", self.name
yield "mouse_over", self.mouse_over
yield "has_focus", self.has_focus
yield "mouse_over", self.mouse_over