new layout

This commit is contained in:
Will McGugan
2022-04-28 13:17:10 +01:00
parent 44c1f2373a
commit 4090d35168
20 changed files with 189 additions and 116 deletions

View File

@@ -41,6 +41,7 @@ App > Screen {
background: $primary-darken-2; background: $primary-darken-2;
color: $text-primary-darken-2 ; color: $text-primary-darken-2 ;
border-right: outer $primary-darken-3; border-right: outer $primary-darken-3;
content-align: center middle;
} }
#sidebar .user { #sidebar .user {
@@ -48,19 +49,21 @@ App > Screen {
background: $primary-darken-1; background: $primary-darken-1;
color: $text-primary-darken-1; color: $text-primary-darken-1;
border-right: outer $primary-darken-3; border-right: outer $primary-darken-3;
content-align: center middle;
} }
#sidebar .content { #sidebar .content {
background: $primary; background: $primary;
color: $text-primary; color: $text-primary;
border-right: outer $primary-darken-3; border-right: outer $primary-darken-3;
content-align: center middle;
} }
#header { #header {
color: $text-primary-darken-1; color: $text-primary-darken-1;
background: $primary-darken-1; background: $primary-darken-1;
height: 3 height: 3;
content-align: center middle;
} }
#content { #content {
@@ -84,6 +87,7 @@ Tweet {
border: wide $panel-darken-2; border: wide $panel-darken-2;
overflow-y: scroll; overflow-y: scroll;
align-horizontal: center; align-horizontal: center;
} }
.scrollable { .scrollable {
@@ -152,6 +156,7 @@ TweetBody {
background: $accent; background: $accent;
height: 1; height: 1;
border-top: hkey $accent-darken-2; border-top: hkey $accent-darken-2;
content-align: center middle;
} }
@@ -165,6 +170,7 @@ OptionItem {
transition: background 100ms linear; transition: background 100ms linear;
border-right: outer $primary-darken-2; border-right: outer $primary-darken-2;
border-left: hidden; border-left: hidden;
content-align: center middle;
} }
OptionItem:hover { OptionItem:hover {

View File

@@ -66,7 +66,7 @@ class Tweet(Widget):
class OptionItem(Widget): class OptionItem(Widget):
def render(self) -> Text: def render(self) -> Text:
return Align.center(Text("Option", justify="center"), vertical="middle") return Text("Option")
class Error(Widget): class Error(Widget):
@@ -95,10 +95,9 @@ class BasicApp(App):
"""Build layout here.""" """Build layout here."""
self.mount( self.mount(
header=Static( header=Static(
Align.center( Text.from_markup(
"[b]This is a [u]Textual[/u] app, running in the terminal", "[b]This is a [u]Textual[/u] app, running in the terminal"
vertical="middle", ),
)
), ),
content=Widget( content=Widget(
Tweet( Tweet(
@@ -140,4 +139,7 @@ class BasicApp(App):
self.panic(self.tree) self.panic(self.tree)
BasicApp.run(css_file="basic.css", watch_css=True, log="textual.log") app = BasicApp(css_file="basic.css", watch_css=True, log="textual.log")
if __name__ == "__main__":
app.run()

0
sandbox/buttons.css Normal file
View File

24
sandbox/buttons.py Normal file
View File

@@ -0,0 +1,24 @@
from textual.app import App, ComposeResult
from textual.widgets import Button
from textual import layout
class ButtonsApp(App[str]):
def compose(self) -> ComposeResult:
yield layout.Vertical(
Button("foo", id="foo"),
Button("bar", id="bar"),
Button("baz", id="baz"),
)
def handle_pressed(self, event: Button.Pressed) -> None:
self.app.bell()
self.exit(event.button.id)
app = ButtonsApp(log="textual.log")
if __name__ == "__main__":
result = app.run()
print(repr(result))

View File

@@ -56,4 +56,7 @@ class BasicApp(App):
sys.stdout.write("abcdef") sys.stdout.write("abcdef")
BasicApp.run(css_file="uber.css", log="textual.log", log_verbosity=1) app = BasicApp(css_file="uber.css", log="textual.log", log_verbosity=1)
if __name__ == "__main__":
app.run()

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
ANSI_COLOR_TO_RGB: dict[str, tuple[int, int, int]] = { COLOR_NAME_TO_RGB: dict[str, tuple[int, int, int]] = {
"black": (0, 0, 0), "black": (0, 0, 0),
"red": (128, 0, 0), "red": (128, 0, 0),
"green": (0, 128, 0), "green": (0, 128, 0),

View File

@@ -9,7 +9,7 @@ import warnings
from asyncio import AbstractEventLoop from asyncio import AbstractEventLoop
from contextlib import redirect_stdout from contextlib import redirect_stdout
from time import perf_counter from time import perf_counter
from typing import Any, Iterable, TextIO, Type, TYPE_CHECKING from typing import Any, Generic, Iterable, TextIO, Type, TypeVar, TYPE_CHECKING
import rich import rich
import rich.repr import rich.repr
@@ -67,6 +67,8 @@ DEFAULT_COLORS = ColorSystem(
dark_surface="#292929", dark_surface="#292929",
) )
ComposeResult = Iterable[Widget]
class AppError(Exception): class AppError(Exception):
pass pass
@@ -76,8 +78,11 @@ class ActionError(Exception):
pass pass
ReturnType = TypeVar("ReturnType")
@rich.repr.auto @rich.repr.auto
class App(DOMNode): class App(Generic[ReturnType], DOMNode):
"""The base class for Textual Applications""" """The base class for Textual Applications"""
css = "" css = ""
@@ -159,6 +164,8 @@ class App(DOMNode):
self.devtools = DevtoolsClient() self.devtools = DevtoolsClient()
self._return_value: ReturnType | None = None
super().__init__() super().__init__()
title: Reactive[str] = Reactive("Textual") title: Reactive[str] = Reactive("Textual")
@@ -166,6 +173,14 @@ class App(DOMNode):
background: Reactive[str] = Reactive("black") background: Reactive[str] = Reactive("black")
dark = Reactive(False) dark = Reactive(False)
def exit(self, result: ReturnType | None = None) -> None:
self._return_value = result
self.close_messages_no_wait()
def compose(self) -> Iterable[Widget]:
return
yield
def get_css_variables(self) -> dict[str, str]: def get_css_variables(self) -> dict[str, str]:
"""Get a mapping of variables used to pre-populate CSS. """Get a mapping of variables used to pre-populate CSS.
@@ -284,27 +299,9 @@ class App(DOMNode):
keys, action, description, show=show, key_display=key_display keys, action, description, show=show, key_display=key_display
) )
@classmethod def run(self, loop: AbstractEventLoop | None = None) -> ReturnType | None:
def run(
cls,
console: Console | None = None,
screen: bool = True,
driver: Type[Driver] | None = None,
loop: AbstractEventLoop | None = None,
**kwargs,
):
"""Run the app.
Args:
console (Console, optional): Console object. Defaults to None.
screen (bool, optional): Enable application mode. Defaults to True.
driver (Type[Driver], optional): Driver class or None for default. Defaults to None.
loop (AbstractEventLoop): Event loop to run the application on. If not specified, uvloop will be used.
"""
async def run_app() -> None: async def run_app() -> None:
app = cls(screen=screen, driver_class=driver, **kwargs) await self.process_messages()
await app.process_messages()
if loop: if loop:
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
@@ -322,6 +319,8 @@ class App(DOMNode):
finally: finally:
event_loop.close() event_loop.close()
return self._return_value
async def _on_css_change(self) -> None: async def _on_css_change(self) -> None:
"""Called when the CSS changes (if watch_css is True).""" """Called when the CSS changes (if watch_css is True)."""
if self.css_file is not None: if self.css_file is not None:
@@ -341,6 +340,9 @@ class App(DOMNode):
self.stylesheet.update(self) self.stylesheet.update(self)
self.screen.refresh(layout=True) self.screen.refresh(layout=True)
def render(self) -> RenderableType:
return ""
def query(self, selector: str | None = None) -> DOMQuery: def query(self, selector: str | None = None) -> DOMQuery:
"""Get a DOM query in the current screen. """Get a DOM query in the current screen.
@@ -547,6 +549,12 @@ class App(DOMNode):
if self._log_file is not None: if self._log_file is not None:
self._log_file.close() self._log_file.close()
def on_mount(self) -> None:
widgets = list(self.compose())
if widgets:
self.mount(*widgets)
self.screen.refresh()
async def on_idle(self) -> None: async def on_idle(self) -> None:
"""Perform actions when there are no messages in the queue.""" """Perform actions when there are no messages in the queue."""
if self._require_styles_update: if self._require_styles_update:

View File

@@ -24,7 +24,7 @@ from rich.style import Style
from rich.text import Text from rich.text import Text
from ._color_constants import ANSI_COLOR_TO_RGB from ._color_constants import COLOR_NAME_TO_RGB
from .geometry import clamp from .geometry import clamp
@@ -123,6 +123,10 @@ class Color(NamedTuple):
), ),
) )
@property
def is_transparent(self) -> bool:
return self.a == 0
@property @property
def clamped(self) -> Color: def clamped(self) -> Color:
"""Get a color with all components saturated to maximum and minimum values.""" """Get a color with all components saturated to maximum and minimum values."""
@@ -253,7 +257,9 @@ class Color(NamedTuple):
""" """
if isinstance(color_text, Color): if isinstance(color_text, Color):
return color_text return color_text
ansi_color = ANSI_COLOR_TO_RGB.get(color_text) if color_text == "transparent":
return TRANSPARENT
ansi_color = COLOR_NAME_TO_RGB.get(color_text)
if ansi_color is not None: if ansi_color is not None:
return cls(*ansi_color) return cls(*ansi_color)
color_match = RE_COLOR.match(color_text) color_match = RE_COLOR.match(color_text)
@@ -329,6 +335,7 @@ class Color(NamedTuple):
# Color constants # Color constants
WHITE = Color(255, 255, 255) WHITE = Color(255, 255, 255)
BLACK = Color(0, 0, 0) BLACK = Color(0, 0, 0)
TRANSPARENT = Color(0, 0, 0, 0)
class ColorPair(NamedTuple): class ColorPair(NamedTuple):

View File

@@ -31,7 +31,7 @@ from .transition import Transition
from ..geometry import Spacing, SpacingDimensions, clamp from ..geometry import Spacing, SpacingDimensions, clamp
if TYPE_CHECKING: if TYPE_CHECKING:
from ..layout import Layout from .._layout import Layout
from .styles import DockGroup, Styles, StylesBase from .styles import DockGroup, Styles, StylesBase

View File

@@ -64,7 +64,7 @@ else:
if TYPE_CHECKING: if TYPE_CHECKING:
from ..dom import DOMNode from ..dom import DOMNode
from ..layout import Layout from .._layout import Layout
class RulesMap(TypedDict, total=False): class RulesMap(TypedDict, total=False):
@@ -173,7 +173,7 @@ class StylesBase(ABC):
layout = LayoutProperty() layout = LayoutProperty()
color = ColorProperty(Color(255, 255, 255)) color = ColorProperty(Color(255, 255, 255))
background = ColorProperty(Color(0, 0, 0)) background = ColorProperty(Color(0, 0, 0, 0))
text_style = StyleFlagsProperty() text_style = StyleFlagsProperty()
opacity = FractionalProperty() opacity = FractionalProperty()

View File

@@ -10,6 +10,7 @@ from rich.text import Text
from rich.tree import Tree from rich.tree import Tree
from ._node_list import NodeList from ._node_list import NodeList
from .color import Color
from .css._error_tools import friendly_list from .css._error_tools import friendly_list
from .css.constants import VALID_DISPLAY, VALID_VISIBILITY from .css.constants import VALID_DISPLAY, VALID_VISIBILITY
from .css.errors import StyleValueError from .css.errors import StyleValueError
@@ -73,18 +74,12 @@ class DOMNode(MessagePump):
yield "classes", " ".join(self._classes) yield "classes", " ".join(self._classes)
@property @property
def parent(self) -> DOMNode: def parent(self) -> DOMNode | None:
"""Get the parent node. """Get the parent node.
Raises:
NoParent: If this is the root node.
Returns: Returns:
DOMNode: The node which is the direct parent of this node. DOMNode: The node which is the direct parent of this node.
""" """
if self._parent is None:
raise NoParent(f"{self} has no parent")
assert isinstance(self._parent, DOMNode)
return self._parent return self._parent
@property @property
@@ -231,19 +226,6 @@ class DOMNode(MessagePump):
f"expected {friendly_list(VALID_VISIBILITY)})" f"expected {friendly_list(VALID_VISIBILITY)})"
) )
@property
def rich_text_style(self) -> Style:
"""Get the text style (added to parent style).
Returns:
Style: Rich Style object.
"""
return (
self.parent.rich_text_style + self.styles.rich_style
if self.has_parent
else self.styles.rich_style
)
@property @property
def tree(self) -> Tree: def tree(self) -> Tree:
"""Get a Rich tree object which will recursively render the structure of the node tree. """Get a Rich tree object which will recursively render the structure of the node tree.
@@ -287,6 +269,48 @@ class DOMNode(MessagePump):
add_children(tree, self) add_children(tree, self)
return tree return tree
@property
def rich_text_style(self) -> Style:
"""Get the text style object.
A widget's style is influenced by its parent. For instance if a widgets background has an alpha,
then its parent's background color will show throw. Additionally, widgets will inherit their
parent's text style (i.e. bold, italic etc).
Returns:
Style: Rich Style object.
"""
# TODO: Feels like there may be opportunity for caching here.
background = Color(0, 0, 0, 0)
color = Color(255, 255, 255, 0)
style = Style()
for node in reversed(self.ancestors):
styles = node.styles
if styles.has_rule("background"):
background += styles.background
if styles.has_rule("color"):
color = styles.color
style += styles.text_style
style = Style(bgcolor=background.rich_color, color=color.rich_color) + style
return style
@property
def ancestors(self) -> list[DOMNode]:
"""Get a list of Nodes by tracing ancestors all the way back to App."""
nodes: list[DOMNode] = [self]
add_node = nodes.append
node = self
while True:
node = node.parent
if node is None:
break
add_node(node)
return nodes
def get_pseudo_classes(self) -> Iterable[str]: def get_pseudo_classes(self) -> Iterable[str]:
"""Get any pseudo classes applicable to this Node, e.g. hover, focus. """Get any pseudo classes applicable to this Node, e.g. hover, focus.

View File

@@ -1,41 +1,17 @@
from __future__ import annotations from .widget import Widget
from abc import ABC, abstractmethod
from typing import ClassVar, NamedTuple, TYPE_CHECKING
from .geometry import Region, Offset, Size class Vertical(Widget):
CSS = """
Vertical {
layout: vertical;
}
"""
if TYPE_CHECKING: class Horizontal(Widget):
from .widget import Widget CSS = """
from .screen import Screen Horizontal {
layout: horizontal;
}
class WidgetPlacement(NamedTuple): """
"""The position, size, and relative order of a widget within its parent."""
region: Region
widget: Widget | None = None # A widget of None means empty space
order: int = 0
class Layout(ABC):
"""Responsible for arranging Widgets in a view and rendering them."""
name: ClassVar[str] = ""
@abstractmethod
def arrange(
self, parent: Widget, size: Size, scroll: Offset
) -> tuple[list[WidgetPlacement], set[Widget]]:
"""Generate a layout map that defines where on the screen the widgets will be drawn.
Args:
parent (Widget): Parent widget.
size (Size): Size of container.
scroll (Offset): Offset to apply to the Widget placements.
Returns:
Iterable[WidgetPlacement]: An iterable of widget location
"""

View File

@@ -9,7 +9,7 @@ from typing import Iterable, TYPE_CHECKING, NamedTuple, Sequence
from .._layout_resolve import layout_resolve from .._layout_resolve import layout_resolve
from ..css.types import Edge from ..css.types import Edge
from ..geometry import Offset, Region, Size from ..geometry import Offset, Region, Size
from ..layout import Layout, WidgetPlacement from .._layout import Layout, WidgetPlacement
from ..widget import Widget from ..widget import Widget
if sys.version_info >= (3, 8): if sys.version_info >= (3, 8):

View File

@@ -1,7 +1,7 @@
import sys import sys
from .horizontal import HorizontalLayout from .horizontal import HorizontalLayout
from ..layout import Layout from .._layout import Layout
from ..layouts.dock import DockLayout from ..layouts.dock import DockLayout
from ..layouts.grid import GridLayout from ..layouts.grid import GridLayout
from ..layouts.vertical import VerticalLayout from ..layouts.vertical import VerticalLayout

View File

@@ -10,7 +10,7 @@ from typing import Iterable, NamedTuple, TYPE_CHECKING
from .._layout_resolve import layout_resolve from .._layout_resolve import layout_resolve
from ..geometry import Size, Offset, Region from ..geometry import Size, Offset, Region
from ..layout import Layout, WidgetPlacement from .._layout import Layout, WidgetPlacement
if TYPE_CHECKING: if TYPE_CHECKING:
from ..widget import Widget from ..widget import Widget

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import cast from typing import cast
from textual.geometry import Size, Offset, Region from textual.geometry import Size, Offset, Region
from textual.layout import Layout, WidgetPlacement from textual._layout import Layout, WidgetPlacement
from textual.widget import Widget from textual.widget import Widget

View File

@@ -5,7 +5,7 @@ from typing import cast, TYPE_CHECKING
from .. import log from .. import log
from ..geometry import Offset, Region, Size from ..geometry import Offset, Region, Size
from ..layout import Layout, WidgetPlacement from .._layout import Layout, WidgetPlacement
if TYPE_CHECKING: if TYPE_CHECKING:
from ..widget import Widget from ..widget import Widget

View File

@@ -11,7 +11,6 @@ from .geometry import Offset, Region
from ._compositor import Compositor from ._compositor import Compositor
from .reactive import Reactive from .reactive import Reactive
from .widget import Widget from .widget import Widget
from .renderables.gradient import VerticalGradient
@rich.repr.auto @rich.repr.auto
@@ -21,9 +20,10 @@ class Screen(Widget):
CSS = """ CSS = """
Screen { Screen {
layout: vertical; layout: dock;
docks: _default=top; docks: _default=top;
background: $surface; background: $surface;
color: $text-surface;
} }
""" """
@@ -38,12 +38,8 @@ class Screen(Widget):
def watch_dark(self, dark: bool) -> None: def watch_dark(self, dark: bool) -> None:
pass pass
@property def render(self) -> RenderableType:
def is_transparent(self) -> bool: return self.app.render()
return False
# def render(self) -> RenderableType:
# return VerticalGradient("red", "blue")
def get_offset(self, widget: Widget) -> Offset: def get_offset(self, widget: Widget) -> Offset:
"""Get the absolute offset of a given Widget. """Get the absolute offset of a given Widget.

View File

@@ -33,7 +33,7 @@ from .dom import DOMNode
from .geometry import clamp, Offset, Region, Size, Spacing from .geometry import clamp, Offset, Region, Size, Spacing
from .message import Message from .message import Message
from . import messages from . import messages
from .layout import Layout from ._layout import Layout
from .reactive import Reactive, watch from .reactive import Reactive, watch
from .renderables.opacity import Opacity from .renderables.opacity import Opacity
@@ -111,8 +111,16 @@ class Widget(DOMNode):
show_vertical_scrollbar = Reactive(False, layout=True) show_vertical_scrollbar = Reactive(False, layout=True)
show_horizontal_scrollbar = Reactive(False, layout=True) show_horizontal_scrollbar = Reactive(False, layout=True)
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
self.app.register(self, *anon_widgets, **widgets)
self.screen.refresh()
def compose(self) -> Iterable[Widget]:
return
yield
def on_register(self, app: App) -> None: def on_register(self, app: App) -> None:
self.app.stylesheet.parse(self.CSS, path=f"<{self.__class__.name}>") self.app.stylesheet.parse(self.CSS, path=f"<{self.__class__.__name__}>")
def get_box_model(self, container: Size, viewport: Size) -> BoxModel: def get_box_model(self, container: Size, viewport: Size) -> BoxModel:
"""Process the box model for this widget. """Process the box model for this widget.
@@ -246,6 +254,18 @@ class Widget(DOMNode):
enabled = self.show_vertical_scrollbar, self.show_horizontal_scrollbar enabled = self.show_vertical_scrollbar, self.show_horizontal_scrollbar
return enabled return enabled
@property
def background_color(self) -> Color:
color = self.styles.background
colors: list[Color] = [color]
add_color = colors.append
node = self
while color.a < 1 and node.parent is not None:
node = node.parent
color = node.styles.background
add_color(color)
return sum(reversed(colors), start=Color(0, 0, 0, 0))
def set_dirty(self) -> None: def set_dirty(self) -> None:
"""Set the Widget as 'dirty' (requiring re-render).""" """Set the Widget as 'dirty' (requiring re-render)."""
self._dirty_regions.clear() self._dirty_regions.clear()
@@ -491,8 +511,7 @@ class Widget(DOMNode):
Returns: Returns:
bool: ``True`` if there is background color, otherwise ``False``. bool: ``True`` if there is background color, otherwise ``False``.
""" """
return False return self.is_container and self.styles.background.is_transparent
return self.layout is not None
@property @property
def console(self) -> Console: def console(self) -> Console:
@@ -631,8 +650,7 @@ class Widget(DOMNode):
if self.is_container: if self.is_container:
return "" return ""
label = self.css_identifier_styled return self.css_identifier_styled
return Align.center(label, vertical="middle")
async def action(self, action: str, *params) -> None: async def action(self, action: str, *params) -> None:
await self.app.action(action, self) await self.app.action(action, self)
@@ -693,6 +711,12 @@ class Widget(DOMNode):
async def on_key(self, event: events.Key) -> None: async def on_key(self, event: events.Key) -> None:
await self.dispatch_key(event) await self.dispatch_key(event)
def on_mount(self, event: events.Mount) -> None:
widgets = list(self.compose())
if widgets:
self.mount(*widgets)
self.screen.refresh()
def on_leave(self) -> None: def on_leave(self) -> None:
self.mouse_over = False self.mouse_over = False

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import cast
from rich.console import RenderableType from rich.console import RenderableType
from rich.text import Text from rich.text import Text
@@ -30,14 +32,15 @@ class Button(Widget, can_focus=True):
Button:hover { Button:hover {
background:$primary-darken-2; background:$primary-darken-2;
color: $text-primary-darken-2; color: $text-primary-darken-2;
border: tall $primary-lighten-1; border: tall $primary-lighten-1;
} }
""" """
class Pressed(Message, bubble=True): class Pressed(Message, bubble=True):
pass @property
def button(self) -> Button:
return cast(Button, self.sender)
def __init__( def __init__(
self, self,