Merge pull request #113 from willmcgugan/0.1.12

0.1.12
This commit is contained in:
Will McGugan
2021-09-20 15:15:42 +01:00
committed by GitHub
22 changed files with 175 additions and 72 deletions

View File

@@ -5,6 +5,16 @@ 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.12] - 2021-09-20
### Added
- Added geometry.Spacing
### Fixed
- Fixed calculation of virtual size in scroll views
## [0.1.11] - 2021-09-12
### Changed

View File

@@ -1,4 +1,3 @@
from textual import events
from textual.app import App
from textual.widgets import Placeholder
@@ -6,7 +5,7 @@ from textual.widgets import Placeholder
class SimpleApp(App):
"""Demonstrates smooth animation"""
async def on_mount(self, event: events.Mount) -> None:
async def on_mount(self) -> None:
"""Build layout here."""
await self.view.dock(Placeholder(), edge="left", size=40)

View File

@@ -20,10 +20,10 @@ class MyApp(App):
async def add_content():
table = Table(title="Demo")
for i in range(40):
for i in range(20):
table.add_column(f"Col {i + 1}", style="magenta")
for i in range(200):
table.add_row(*[f"cell {i},{j}" for j in range(40)])
for i in range(100):
table.add_row(*[f"cell {i},{j}" for j in range(20)])
await body.update(table)

View File

@@ -12,7 +12,6 @@ from rich.padding import Padding
from rich.text import Text
from textual.app import App
from textual import events
from textual.reactive import Reactive
from textual.views import GridView
from textual.widget import Widget
@@ -186,7 +185,7 @@ class Calculator(GridView):
self.display = self.value = str(Decimal(self.value or "0") / Decimal(100))
elif button_name == ".":
if "." not in self.value:
self.display = self.value = self.value + "."
self.display = self.value = (self.value or "0") + "."
elif button_name == "AC":
self.value = ""
self.left = self.right = Decimal(0)

View File

@@ -17,7 +17,7 @@ class MyApp(App):
"""Create and dock the widgets."""
# A scrollview to contain the markdown file
body = ScrollView()
body = ScrollView(gutter=1)
# Header / footer / dock
await self.view.dock(Header(), edge="top")

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "textual"
version = "0.1.11"
version = "0.1.12"
homepage = "https://github.com/willmcgugan/textual"
description = "Text User Interface using Rich"
authors = ["Will McGugan <willmcgugan@gmail.com>"]

View File

@@ -135,9 +135,10 @@ class App(MessagePump):
if self.log_file and verbosity <= self.log_verbosity:
output = f" ".join(str(arg) for arg in args)
if kwargs:
output += " " + " ".join(
key_values = " ".join(
f"{key}={value}" for key, value in kwargs.items()
)
output = " ".join((output, key_values))
self.log_file.write(output + "\n")
self.log_file.flush()
except Exception:
@@ -301,11 +302,6 @@ class App(MessagePump):
if self.log_file is not None:
self.log_file.close()
async def call_later(self, callback: Callable, *args, **kwargs) -> None:
await self.post_message(
events.Callback(self, partial(callback, *args, **kwargs))
)
def register(self, child: MessagePump, parent: MessagePump) -> bool:
if child not in self.children:
self.children.add(child)

29
src/textual/background.py Normal file
View File

@@ -0,0 +1,29 @@
from rich.console import Console, ConsoleOptions, RenderResult
from rich.segment import Segment, SegmentLines
from rich.style import StyleType
from .widget import Widget
class BackgroundRenderable:
def __init__(self, style: StyleType) -> None:
self.style = style
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
width = options.max_width
height = options.height or console.height
style = console.get_style(self.style)
blank_segment = Segment(" " * width, style)
lines = SegmentLines([[blank_segment]] * height, new_lines=True)
yield lines
class Background(Widget):
def __init__(self, style: StyleType = "on blue") -> None:
self.background_style = style
def render(self) -> BackgroundRenderable:
return BackgroundRenderable(self.background_style)

View File

@@ -7,6 +7,7 @@ from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from . import events
from . import log
from ._types import MessageTarget
if TYPE_CHECKING:

View File

@@ -333,7 +333,7 @@ class MouseUp(MouseEvent, bubble=True):
pass
class MouseScrollDown(InputEvent, bubble=True):
class MouseScrollDown(InputEvent, verbosity=3, bubble=True):
__slots__ = ["x", "y"]
def __init__(self, sender: MessageTarget, x: int, y: int) -> None:

View File

@@ -1,6 +1,9 @@
from __future__ import annotations
from typing import Any, NamedTuple, TypeVar
from typing import Any, cast, NamedTuple, Tuple, Union, TypeVar
SpacingDimensions = Union[int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]]
T = TypeVar("T", int, float)
@@ -262,7 +265,7 @@ class Region(NamedTuple):
return NotImplemented
def expand(self, size: tuple[int, int]) -> Region:
"""Add additional height.
"""Increase the size of the region by adding a border.
Args:
size (tuple[int, int]): Additional width and height.
@@ -270,9 +273,14 @@ class Region(NamedTuple):
Returns:
Region: A new region.
"""
add_width, add_height = size
expand_width, expand_height = size
x, y, width, height = self
return Region(x, y, width + add_width, height + add_height)
return Region(
x - expand_width,
y - expand_height,
width + expand_width * 2,
height + expand_height * 2,
)
def overlaps(self, other: Region) -> bool:
"""Check if another region overlaps this region.
@@ -419,3 +427,48 @@ class Region(NamedTuple):
min(x1, ox1), min(y1, oy1), max(x2, ox2), max(y2, oy2)
)
return union_region
class Spacing(NamedTuple):
"""The spacing around a renderable."""
top: int = 0
right: int = 0
bottom: int = 0
left: int = 0
@property
def width(self) -> int:
"""Total space in width."""
return self.left + self.right
@property
def height(self) -> int:
"""Total space in height."""
return self.top + self.bottom
@property
def top_left(self) -> tuple[int, int]:
"""Top left space."""
return (self.left, self.top)
@property
def bottom_right(self) -> tuple[int, int]:
"""Bottom right space."""
return (self.right, self.bottom)
@classmethod
def unpack(cls, pad: SpacingDimensions) -> Spacing:
"""Unpack padding specified in CSS style."""
if isinstance(pad, int):
return cls(pad, pad, pad, pad)
if len(pad) == 1:
_pad = pad[0]
return cls(_pad, _pad, _pad, _pad)
if len(pad) == 2:
pad_top, pad_right = cast(Tuple[int, int], pad)
return cls(pad_top, pad_right, pad_top, pad_right)
if len(pad) == 4:
top, right, bottom, left = cast(Tuple[int, int, int, int], pad)
return cls(top, right, bottom, left)
raise ValueError(f"1, 2 or 4 integers required for spacing; {len(pad)} given")

View File

@@ -2,9 +2,10 @@ from __future__ import annotations
from typing import Iterable
from ..geometry import Offset, Region, Size
from ..geometry import Offset, Region, Size, Spacing, SpacingDimensions
from ..layout import Layout, WidgetPlacement
from ..widget import Widget
from .._loop import loop_last
class VerticalLayout(Layout):
@@ -13,11 +14,11 @@ class VerticalLayout(Layout):
*,
auto_width: bool = False,
z: int = 0,
gutter: tuple[int, int] | None = None
gutter: SpacingDimensions = (0, 0, 0, 0)
):
self.auto_width = auto_width
self.z = z
self.gutter = gutter or (0, 0)
self.gutter = Spacing.unpack(gutter)
self._widgets: list[Widget] = []
self._max_widget_width = 0
super().__init__()
@@ -36,18 +37,19 @@ class VerticalLayout(Layout):
def arrange(self, size: Size, scroll: Offset) -> Iterable[WidgetPlacement]:
index = 0
width, _height = size
gutter_height, gutter_width = self.gutter
gutter = self.gutter
x, y = self.gutter.top_left
render_width = (
max(width, self._max_widget_width) + gutter_width * 2
max(width, self._max_widget_width)
if self.auto_width
else width - gutter_width * 2
else width - gutter.width
)
x = gutter_width
y = gutter_height
total_width = render_width
total_region = Region()
for widget in self._widgets:
gutter_height = max(gutter.top, gutter.bottom)
for last, widget in loop_last(self._widgets):
if (
not widget.render_cache
or widget.render_cache.size.width != render_width
@@ -57,6 +59,6 @@ class VerticalLayout(Layout):
render_height = widget.render_cache.size.height
region = Region(x, y, render_width, render_height)
yield WidgetPlacement(region, widget, (self.z, index))
total_region = total_region.union(region)
y += render_height + (gutter.bottom if last else gutter_height)
yield WidgetPlacement(total_region)
yield WidgetPlacement(Region(0, 0, total_width + gutter.width, y))

View File

@@ -77,7 +77,7 @@ class Message:
"""
return False
def prevent_default(self, prevent: bool = True) -> None:
def prevent_default(self, prevent: bool = True) -> Message:
"""Suppress the default action.
Args:
@@ -85,14 +85,16 @@ class Message:
or False if the default actions should be performed. Defaults to True.
"""
self._no_default_action = prevent
return self
def stop(self, stop: bool = True) -> None:
def stop(self, stop: bool = True) -> Message:
"""Stop propagation of the message to parent.
Args:
stop (bool, optional): The stop flag. Defaults to True.
"""
self._stop_propagation = stop
return self
async def wait(self) -> None:
"""Wait for the message to be processed."""

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
from asyncio import CancelledError
from asyncio import Queue, QueueEmpty, Task
from functools import partial
from typing import TYPE_CHECKING, Awaitable, Iterable, Callable
from weakref import WeakSet
@@ -144,10 +145,20 @@ class MessagePump:
self._child_tasks.add(asyncio.get_event_loop().create_task(timer.run()))
return timer
async def call_later(self, callback: Callable, *args, **kwargs) -> None:
"""Run a callback after processing all messages and refreshing the screen.
Args:
callback (Callable): A callable.
"""
await self.post_message(
events.Callback(self, partial(callback, *args, **kwargs))
)
def close_messages_no_wait(self) -> None:
self._message_queue.put_nowait(None)
async def close_messages(self, wait: bool = True) -> None:
async def close_messages(self) -> None:
"""Close message queue, and optionally wait for queue to finish processing."""
if self._closed:
return

View File

@@ -129,7 +129,6 @@ class Reactive(Generic[ReactiveType]):
except AttributeError:
continue
value = await invoke(compute_method)
# value = await compute_method()
setattr(obj, compute, value)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import rich.repr
from rich.color import Color
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
@@ -237,14 +238,20 @@ class ScrollBar(Widget):
x: float | None = None
y: float | None = None
if self.vertical:
y = self.grabbed_position + (
(event.screen_y - self.grabbed.y)
* (self.virtual_size / self.window_size)
y = round(
self.grabbed_position
+ (
(event.screen_y - self.grabbed.y)
* (self.virtual_size / self.window_size)
)
)
else:
x = self.grabbed_position + (
(event.screen_x - self.grabbed.x)
* (self.virtual_size / self.window_size)
x = round(
self.grabbed_position
+ (
(event.screen_x - self.grabbed.x)
* (self.virtual_size / self.window_size)
)
)
await self.emit(ScrollTo(self, x=x, y=y))

View File

@@ -106,13 +106,14 @@ class View(Widget):
assert isinstance(widget, Widget)
display_update = self.layout.update_widget(self.console, widget)
# self.log("UPDATE", widget, display_update)
if display_update is not None:
self.app.display(display_update)
async def handle_layout(self, message: messages.Layout) -> None:
await self.refresh_layout()
if self.is_root_view:
message.stop()
await self.refresh_layout()
self.app.refresh()
async def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
@@ -142,9 +143,7 @@ class View(Widget):
return
hidden, shown, resized = self.layout.reflow(self, Size(*self.console.size))
assert self.layout.map is not None
# self.virtual_size = self.layout.map.virtual_size
for widget in hidden:
widget.post_message_no_wait(events.Hide(self))

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from rich.console import RenderableType
from .. import events
from ..geometry import Size
from ..geometry import Size, SpacingDimensions
from ..layouts.vertical import VerticalLayout
from ..view import View
from ..message import Message
@@ -23,7 +23,7 @@ class WindowView(View, layout=VerticalLayout):
widget: RenderableType | Widget,
*,
auto_width: bool = False,
gutter: tuple[int, int] = (0, 1),
gutter: SpacingDimensions = (0, 0),
name: str | None = None
) -> None:
layout = VerticalLayout(gutter=gutter, auto_width=auto_width)

View File

@@ -16,9 +16,8 @@ from rich import box
from rich.align import Align
from rich.console import Console, RenderableType
from rich.panel import Panel
from rich.padding import Padding, PaddingDimensions
from rich.padding import Padding
from rich.pretty import Pretty
from rich.segment import Segment
from rich.style import Style
from rich.styled import Styled
from rich.text import TextType
@@ -27,7 +26,7 @@ from . import events
from ._animator import BoundAnimator
from ._callback import invoke
from ._context import active_app
from .geometry import Size
from .geometry import Size, Spacing, SpacingDimensions
from .message import Message
from .message_pump import MessagePump
from .messages import Layout, Update
@@ -41,15 +40,6 @@ if TYPE_CHECKING:
log = getLogger("rich")
class Spacing(NamedTuple):
"""The spacing around a renderable."""
top: int = 0
right: int = 0
bottom: int = 0
left: int = 0
class RenderCache(NamedTuple):
size: Size
lines: Lines
@@ -103,11 +93,11 @@ class Widget(MessagePump):
BOX_MAP = {"normal": box.SQUARE, "round": box.ROUNDED, "bold": box.HEAVY}
def validate_padding(self, padding: PaddingDimensions) -> Spacing:
return Spacing(*Padding.unpack(padding))
def validate_padding(self, padding: SpacingDimensions) -> Spacing:
return Spacing.unpack(padding)
def validate_margin(self, padding: PaddingDimensions) -> Spacing:
return Spacing(*Padding.unpack(padding))
def validate_margin(self, margin: SpacingDimensions) -> Spacing:
return Spacing.unpack(margin)
def validate_layout_offset_x(self, value) -> int:
return int(value)

View File

@@ -63,4 +63,5 @@ class Button(Widget):
return ButtonRenderable(self.label, style=self.button_style)
async def on_click(self, event: events.Click) -> None:
event.prevent_default().stop()
await self.emit(ButtonPressed(self))

View File

@@ -8,7 +8,6 @@ import os.path
from rich.console import RenderableType
import rich.repr
from rich.text import Text
from rich.tree import Tree
from .. import events
from ..message import Message

View File

@@ -5,12 +5,14 @@ from rich.style import StyleType
from .. import events
from ..geometry import SpacingDimensions
from ..layouts.grid import GridLayout
from ..message import Message
from ..messages import CursorMove
from ..scrollbar import ScrollTo, ScrollBar
from ..geometry import clamp
from ..view import View
from ..widget import Widget
from ..reactive import Reactive
@@ -25,6 +27,7 @@ class ScrollView(View):
name: str | None = None,
style: StyleType = "",
fluid: bool = True,
gutter: SpacingDimensions = (0, 0)
) -> None:
from ..views import WindowView
@@ -32,7 +35,7 @@ class ScrollView(View):
self.vscroll = ScrollBar(vertical=True)
self.hscroll = ScrollBar(vertical=False)
self.window = WindowView(
"" if contents is None else contents, auto_width=auto_width
"" if contents is None else contents, auto_width=auto_width, gutter=gutter
)
layout = GridLayout()
layout.add_column("main")
@@ -66,11 +69,11 @@ class ScrollView(View):
@property
def max_scroll_y(self) -> float:
return max(0, self.window.virtual_size.height - self.size.height)
return max(0, self.window.virtual_size.height - self.window.size.height)
@property
def max_scroll_x(self) -> float:
return max(0, self.window.virtual_size.width - self.size.width)
return max(0, self.window.virtual_size.width - self.window.size.width)
async def watch_x(self, new_value: float) -> None:
self.window.scroll_x = round(new_value)
@@ -193,7 +196,10 @@ class ScrollView(View):
self.animate("x", self.target_x, speed=150, easing="out_cubic")
self.animate("y", self.target_y, speed=150, easing="out_cubic")
def handle_window_change(self, message) -> None:
async def handle_window_change(self, message: Message) -> None:
message.stop()
virtual_width, virtual_height = self.window.virtual_size
width, height = self.size
@@ -207,10 +213,10 @@ class ScrollView(View):
assert isinstance(self.layout, GridLayout)
if self.layout.show_column("vscroll", virtual_height > height):
self.refresh()
if self.layout.show_row("hscroll", virtual_width > width):
self.refresh()
vscroll_change = self.layout.show_column("vscroll", virtual_height > height)
hscroll_change = self.layout.show_row("hscroll", virtual_width > width)
if hscroll_change or vscroll_change:
self.refresh(layout=True)
def handle_cursor_move(self, message: CursorMove) -> None:
self.scroll_to_center(message.line)