mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
10
CHANGELOG.md
10
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>"]
|
||||
|
||||
@@ -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
29
src/textual/background.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -129,7 +129,6 @@ class Reactive(Generic[ReactiveType]):
|
||||
except AttributeError:
|
||||
continue
|
||||
value = await invoke(compute_method)
|
||||
# value = await compute_method()
|
||||
setattr(obj, compute, value)
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user