diff --git a/CHANGELOG.md b/CHANGELOG.md index 9077cffff..dfe88fbc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [0.1.9] - Unreleased +## [0.1.10] - Unreleased + +### Changed + +- Allowed callbacks to be async or non-async, and for event to be optional. +- Fixed exception in clock example https://github.com/willmcgugan/textual/issues/52 + +## [0.1.9] - 2021-08-06 ### Added diff --git a/README.md b/README.md index 1595fcfdb..20af6c947 100644 --- a/README.md +++ b/README.md @@ -56,14 +56,14 @@ from textual.app import App class Beeper(App): - async def on_key(self, event): + def on_key(self): self.console.bell() Beeper.run() ``` -Here we can see a textual app with a single `on_key` method which will receive key events. Any key event will result in playing the terminal bell (which will generally emit an irritating beep). Hit Ctrl+C to exit. +Here we can see a textual app with a single `on_key` method which will handle key events. Pressing any key will result in playing the terminal bell (generally an irritating beep). Hit Ctrl+C to exit. Event handlers in Textual are defined by convention, not by inheritance (so you won't find an `on_key` method in the base class). Each event has a `name` attribute which for the key event is simply `"key"`. Textual will call the method named `on_` if it exists. @@ -74,7 +74,7 @@ from textual.app import App class ColorChanger(App): - async def on_key(self, event): + def on_key(self, event): if event.key.isdigit(): self.background = f"on color({event.key})" @@ -82,7 +82,9 @@ class ColorChanger(App): ColorChanger.run(log="textual.log") ``` -This example also handles key events, and will set `App.background` if the key is a digit. So pressing the keys 0 to 9 will change the background color to the corresponding [ansi color](https://rich.readthedocs.io/en/latest/appendix/colors.html). +You'll notice that the `on_key` method above contains an additional `event` parameter which wasn't present on the beeper example. If the `event` argument is present, Textual will call the handler with an event object. Every event has an associated handler object, in this case it is a KeyEvent which contains additional information regarding which key was pressed. + +The key event handler above will set the background attribute if you press the keys 0-9, which turns the terminal to the corresponding [ansi color](https://rich.readthedocs.io/en/latest/appendix/colors.html). Note that we didn't need to explicitly refresh the screen or draw anything. Setting the `background` attribute to a [Rich style](https://rich.readthedocs.io/en/latest/style.html) is enough for Textual to update the visuals. This is an example of _reactivity_ in Textual. To make changes to the terminal interface you modify the _state_ and let Textual update the UI. @@ -90,17 +92,16 @@ Note that we didn't need to explicitly refresh the screen or draw anything. Sett To make more interesting apps you will need to make use of _widgets_, which are independent user interface elements. Textual comes with a (growing) library of widgets, but you can develop your own. -Let's look at an app which contains widgets. We will be using the built in `Placeholder` widget which you can use to design application layouts before you implement the real content. +Let's look at an app which contains widgets. We will be using the built-in `Placeholder` widget which you can use to design application layouts before you implement the real content. ```python -from textual import events from textual.app import App from textual.widgets import Placeholder class SimpleApp(App): - async def on_mount(self, event: events.Mount) -> None: + async def on_mount(self) -> None: await self.view.dock(Placeholder(), edge="left", size=40) await self.view.dock(Placeholder(), Placeholder(), edge="top") @@ -108,7 +109,9 @@ class SimpleApp(App): SimpleApp.run(log="textual.log") ``` -This app contains a single event handler `on_mount`. The mount event is sent when the app or widget is ready to start processing events, and is typically used for initialization. In this case we are going to call `self.view.dock` to add widgets to the interface. +This app contains a single event handler `on_mount`. The mount event is sent when the app or widget is ready to start processing events, and is typically used for initialization. You may have noticed that `on_mount` is an `async` function. Since Textual is an asynchronous framework we will need this if we need to call most other methods. + +The `on_mount` method makes two calls to `self.view.dock` which adds widgets to tht terminal. Here's the first line in the mount handler: @@ -145,7 +148,6 @@ Let's look at an example with a custom widget: ```python from rich.panel import Panel -from textual import events from textual.app import App from textual.reactive import Reactive from textual.widget import Widget @@ -153,22 +155,22 @@ from textual.widget import Widget class Hover(Widget): - mouse_over: Reactive[bool] = Reactive(False) + mouse_over = Reactive(False) def render(self) -> Panel: return Panel("Hello [b]World[/b]", style=("on red" if self.mouse_over else "")) - async def on_enter(self, event: events.Enter) -> None: + def on_enter(self) -> None: self.mouse_over = True - async def on_leave(self, event: events.Leave) -> None: + def on_leave(self) -> None: self.mouse_over = False class HoverApp(App): - """Hover widget demonstration.""" + """Demonstrates custom widgets""" - async def on_mount(self, event: events.Mount) -> None: + async def on_mount(self) -> None: hovers = (Hover() for _ in range(10)) await self.view.dock(*hovers, edge="top") @@ -179,26 +181,34 @@ HoverApp.run(log="textual.log") The `Hover` class is a custom widget which displays a panel containing the classic text "Hello World". The first line in the Hover class may seem a little mysterious at this point: ```python -mouse_over: Reactive[bool] = Reactive(False) +mouse_over = Reactive(False) ``` -This adds a `mouse_over` attribute to your class which is a bool with a default of `False`. The typing part (`Reactive[bool]`) is not required, but will help you find bugs if you are using a tool like [Mypy](https://mypy.readthedocs.io/en/stable/). Adding attributes like this makes them _reactive_, and any changes will result in the widget updating. +This adds a `mouse_over` attribute to your class which is a bool with a default of `False`. Adding attributes like this makes them _reactive_: any changes will result in the widget updating. -The following `render()` method is where you define how the widget should be displayed. In the Hover widget we return a Panel containing rich text with a background that changes depending on the value of `mouse_over`. The goal here is to add a mouse hover effect to the widget, which we can achieve by handling two events: `Enter` and `Leave`. These events are sent when the mouse enters or leaves the widget. +The following `render()` method is where you define how the widget should be displayed. In the Hover widget we return a [Panel](https://rich.readthedocs.io/en/latest/panel.html) containing rich text with a background that changes depending on the value of `mouse_over`. The goal here is to add a mouse hover effect to the widget, which we can achieve by handling two events: `Enter` and `Leave`. These events are sent when the mouse enters or leaves the widget. Here are the two event handlers again: ```python - async def on_enter(self, event: events.Enter) -> None: + def on_enter(self) -> None: self.mouse_over = True - async def on_leave(self, event: events.Leave) -> None: + def on_leave(self) -> None: self.mouse_over = False ``` Both event handlers set the `mouse_over` attribute which, because it is reactive, will result in the widget's `render()` method being called. -The app class has a `Mount` handler where we _dock_ 10 of these custom widgets from the top edge, stacking them vertically. If you run this script you will see something like the following: +The `HoverApp` has a `on_mount` handler which creates 10 Hover widgets and docks them on the top edge, creating a vertical stack. + +```python + async def on_mount(self) -> None: + hovers = (Hover() for _ in range(10)) + await self.view.dock(*hovers, edge="top") +``` + +If you run this script you will see something like the following: ![widgets](./imgs/custom.gif) diff --git a/docs/examples/actions/colorizer.py b/docs/examples/actions/colorizer.py index 8ba59cfa6..4034f7a8d 100644 --- a/docs/examples/actions/colorizer.py +++ b/docs/examples/actions/colorizer.py @@ -2,7 +2,7 @@ from textual.app import App class Colorizer(App): - async def on_load(self, event): + async def on_load(self): await self.bind("r", "color('red')") await self.bind("g", "color('green')") await self.bind("b", "color('blue')") diff --git a/docs/examples/actions/q.py b/docs/examples/actions/q.py deleted file mode 100644 index 682be8591..000000000 --- a/docs/examples/actions/q.py +++ /dev/null @@ -1,9 +0,0 @@ -from textual.app import App - - -class Quiter(App): - async def on_load(self, event): - await self.bind("q", "quit") - - -Quiter.run() diff --git a/docs/examples/messages_and_events/beep.py b/docs/examples/messages_and_events/beep.py index 6dfa99a6a..7cbd54de0 100644 --- a/docs/examples/messages_and_events/beep.py +++ b/docs/examples/messages_and_events/beep.py @@ -2,7 +2,7 @@ from textual.app import App class Beeper(App): - async def on_key(self, event): + def on_key(self): self.console.bell() diff --git a/docs/examples/messages_and_events/color_changer.py b/docs/examples/messages_and_events/color_changer.py index 8507b2336..7d43a65e7 100644 --- a/docs/examples/messages_and_events/color_changer.py +++ b/docs/examples/messages_and_events/color_changer.py @@ -2,7 +2,7 @@ from textual.app import App class ColorChanger(App): - async def on_key(self, event): + def on_key(self, event): if event.key.isdigit(): self.background = f"on color({event.key})" diff --git a/docs/examples/widgets/custom.py b/docs/examples/widgets/custom.py index 7be00ed8a..6856bcb20 100644 --- a/docs/examples/widgets/custom.py +++ b/docs/examples/widgets/custom.py @@ -1,4 +1,3 @@ -from rich.console import RenderableType from rich.panel import Panel from textual.app import App @@ -10,7 +9,7 @@ class Hover(Widget): mouse_over = Reactive(False) - def render(self) -> RenderableType: + def render(self) -> Panel: return Panel("Hello [b]World[/b]", style=("on red" if self.mouse_over else "")) def on_enter(self) -> None: diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 7743890c6..ef540dae2 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -125,7 +125,7 @@ class Size(NamedTuple): "Dimensions.__contains__ requires an iterable of two integers" ) width, height = self - return bool(width > x >= 0 and height > y >= 0) + return width > x >= 0 and height > y >= 0 class Region(NamedTuple): @@ -164,7 +164,7 @@ class Region(NamedTuple): """ x, y = origin width, height = size - return Region(x, y, width, height) + return cls(x, y, width, height) def __bool__(self) -> bool: return bool(self.width and self.height) @@ -182,7 +182,7 @@ class Region(NamedTuple): return self.x + self.width @property - def y_end(self) -> int: + def y_max(self) -> int: return self.y + self.height @property @@ -216,7 +216,7 @@ class Region(NamedTuple): @property def y_range(self) -> range: - return range(self.y, self.y_end) + return range(self.y, self.y_max) def __add__(self, other: Any) -> Region: if isinstance(other, tuple): diff --git a/src/textual/layout.py b/src/textual/layout.py index f74647ada..610e39e4f 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -307,7 +307,7 @@ class Layout(ABC): height = self.height screen = Region(0, 0, width, height) - crop_region = crop or Region(0, 0, self.width, self.height) + crop_region = crop.intersection(screen) if crop else screen _Segment = Segment divide = _Segment.divide @@ -326,23 +326,15 @@ class Layout(ABC): # Go through all the renders in reverse order and fill buckets with no render renders = list(self._get_renders(console)) - clip_y, clip_y2 = crop_region.y_extents for region, clip, lines in chain( renders, [(screen, screen, background_render)] ): - # clip = clip.intersection(crop_region) render_region = region.intersection(clip) - for y, line in enumerate(lines, render_region.y): - if clip_y > y > clip_y2: - continue - # first_cut = clamp(render_region.x, clip_x, clip_x2) - # last_cut = clamp(render_region.x + render_region.width, clip_x, clip_x2) - first_cut = render_region.x - last_cut = render_region.x_max - final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)] - # final_cuts = cuts[y] + for y, line in zip(render_region.y_range, lines): + + first_cut, last_cut = render_region.x_extents + final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)] - # log(final_cuts, render_region.x_extents) if len(final_cuts) == 2: cut_segments = [line] else: