Merge branch 'css' into invisible-widgets

This commit is contained in:
Will McGugan
2022-09-02 14:02:18 +01:00
committed by GitHub
61 changed files with 572 additions and 360 deletions

View File

@@ -7,7 +7,7 @@ from textual.widget import Widget
class Clock(Widget):
"""A clock app."""
CSS = """
DEFAULT_CSS = """
Clock {
content-align: center middle;
}

View File

@@ -4,7 +4,7 @@ from textual.widgets import Button
class ButtonApp(App):
CSS = """
DEFAULT_CSS = """
Button {
width: 100%;
}

View File

@@ -35,4 +35,4 @@ class SmoothApp(App):
# self.set_timer(10, lambda: self.action("quit"))
SmoothApp.run(log_path="textual.log", log_verbosity=2)
SmoothApp.run(log_path="textual.log")

View File

@@ -8,7 +8,7 @@ from textual.widgets import Placeholder
class VerticalContainer(Widget):
CSS = """
DEFAULT_CSS = """
VerticalContainer {
layout: vertical;
overflow: hidden auto;
@@ -24,7 +24,7 @@ class VerticalContainer(Widget):
class Introduction(Widget):
CSS = """
DEFAULT_CSS = """
Introduction {
background: indigo;
color: white;

View File

@@ -23,7 +23,9 @@ class ButtonsApp(App[str]):
app = ButtonsApp(
log_path="textual.log", css_path="buttons.css", watch_css=True, log_verbosity=2
log_path="textual.log",
css_path="buttons.css",
watch_css=True,
)
if __name__ == "__main__":

View File

@@ -23,7 +23,7 @@ class ColorDisplay(Widget, can_focus=True):
class ColorNames(App):
CSS = """
DEFAULT_CSS = """
ColorDisplay {
height: 1;
}

View File

@@ -23,7 +23,9 @@ class ButtonsApp(App[str]):
app = ButtonsApp(
log_path="textual.log", css_path="buttons.css", watch_css=True, log_verbosity=2
log_path="textual.log",
css_path="buttons.css",
watch_css=True,
)
if __name__ == "__main__":

View File

@@ -62,9 +62,7 @@ class FileSearchApp(App):
self.file_table.filter = event.value
app = FileSearchApp(
log_path="textual.log", css_path="file_search.scss", watch_css=True, log_verbosity=2
)
app = FileSearchApp(log_path="textual.log", css_path="file_search.scss", watch_css=True)
if __name__ == "__main__":
result = app.run()

View File

@@ -5,7 +5,7 @@ from textual.widget import Widget
class FiftyApp(App):
CSS = """
DEFAULT_CSS = """
Screen {
layout: vertical;
}
@@ -24,6 +24,7 @@ class FiftyApp(App):
yield layout.Horizontal(Widget(), Widget())
yield layout.Horizontal(Widget(), Widget())
app = FiftyApp()
if __name__ == "__main__":
app.run()

View File

@@ -61,9 +61,7 @@ class InputApp(App[str]):
self.celsius.value = f"{celsius:.1f}"
app = InputApp(
log_path="textual.log", css_path="input.scss", watch_css=True, log_verbosity=2
)
app = InputApp(log_path="textual.log", css_path="input.scss", watch_css=True)
if __name__ == "__main__":
result = app.run()

View File

@@ -9,7 +9,7 @@ placeholders_count = 12
class VerticalContainer(Widget):
CSS = """
DEFAULT_CSS = """
VerticalContainer {
layout: vertical;
overflow: hidden auto;
@@ -26,7 +26,7 @@ class VerticalContainer(Widget):
class Introduction(Widget):
CSS = """
DEFAULT_CSS = """
Introduction {
background: indigo;
color: white;

View File

@@ -10,7 +10,7 @@ initial_placeholders_count = 4
class VerticalContainer(Widget):
CSS = """
DEFAULT_CSS = """
VerticalContainer {
layout: vertical;
overflow: hidden auto;
@@ -30,7 +30,7 @@ class VerticalContainer(Widget):
class Introduction(Widget):
CSS = """
DEFAULT_CSS = """
Introduction {
background: indigo;
color: white;

View File

@@ -11,7 +11,7 @@ class Thing(Static):
class AddRemoveApp(App):
CSS = """
DEFAULT_CSS = """
#buttons {
dock: top;
height: auto;

View File

@@ -9,8 +9,8 @@ Screen {
table-columns: 1fr;
table-rows: 2fr 1fr 1fr 1fr 1fr 1fr;
margin: 1 2;
min-height:26;
min-width: 50;
min-height:25;
min-width: 26;
}
Button {
@@ -18,18 +18,15 @@ Button {
height: 100%;
}
.display {
#numbers {
column-span: 4;
content-align: right middle;
padding: 0 1;
height: 100%;
background: $panel-darken-2;
background: $primary-lighten-2;
color: $text-primary-lighten-2;
}
.special {
tint: $text-panel 20%;
}
.zero {
#number-0 {
column-span: 2;
}

View File

@@ -1,35 +1,143 @@
from textual.app import App
from decimal import Decimal
from textual.app import App, ComposeResult
from textual import events
from textual.layout import Container
from textual.reactive import Reactive
from textual.widgets import Button, Static
class CalculatorApp(App):
def compose(self):
"""A working 'desktop' calculator."""
numbers = Reactive.var("0")
show_ac = Reactive.var(True)
left = Reactive.var(Decimal("0"))
right = Reactive.var(Decimal("0"))
value = Reactive.var("")
operator = Reactive.var("plus")
KEY_MAP = {
"+": "plus",
"-": "minus",
".": "point",
"*": "multiply",
"/": "divide",
"_": "plus-minus",
"%": "percent",
"=": "equals",
}
def watch_numbers(self, value: str) -> None:
"""Called when numbers is updated."""
# Update the Numbers widget
self.query_one("#numbers", Static).update(value)
def compute_show_ac(self) -> bool:
"""Compute switch to show AC or C button"""
return self.value in ("", "0") and self.numbers == "0"
def watch_show_ac(self, show_ac: bool) -> None:
"""Called when show_ac changes."""
self.query_one("#c").display = not show_ac
self.query_one("#ac").display = show_ac
def compose(self) -> ComposeResult:
"""Add our buttons."""
yield Container(
Static("0", classes="display"),
Button("AC", classes="special"),
Button("+/-", classes="special"),
Button("%", classes="special"),
Button("÷", variant="warning"),
Button("7"),
Button("8"),
Button("9"),
Button("×", variant="warning"),
Button("4"),
Button("5"),
Button("6"),
Button("-", variant="warning"),
Button("1"),
Button("2"),
Button("3"),
Button("+", variant="warning"),
Button("0", classes="operator zero"),
Button("."),
Button("=", variant="warning"),
Static(id="numbers"),
Button("AC", id="ac", variant="primary"),
Button("C", id="c", variant="primary"),
Button("+/-", id="plus-minus", variant="primary"),
Button("%", id="percent", variant="primary"),
Button("÷", id="divide", variant="warning"),
Button("7", id="number-7"),
Button("8", id="number-8"),
Button("9", id="number-9"),
Button("×", id="multiply", variant="warning"),
Button("4", id="number-4"),
Button("5", id="number-5"),
Button("6", id="number-6"),
Button("-", id="minus", variant="warning"),
Button("1", id="number-1"),
Button("2", id="number-2"),
Button("3", id="number-3"),
Button("+", id="plus", variant="warning"),
Button("0", id="number-0"),
Button(".", id="point"),
Button("=", id="equals", variant="warning"),
id="calculator",
)
def on_key(self, event: events.Key) -> None:
"""Called when the user presses a key."""
print(f"KEY {event} was pressed!")
def press(button_id: str) -> None:
self.query_one(f"#{button_id}", Button).press()
self.set_focus(None)
key = event.key
if key.isdecimal():
press(f"number-{key}")
elif key == "c":
press("c")
press("ac")
elif key in self.KEY_MAP:
press(self.KEY_MAP[key])
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Called when a button is pressed."""
button_id = event.button.id
assert button_id is not None
self.bell() # Terminal bell
def do_math() -> None:
"""Does the math: LEFT OPERATOR RIGHT"""
try:
if self.operator == "plus":
self.left += self.right
elif self.operator == "minus":
self.left -= self.right
elif self.operator == "divide":
self.left /= self.right
elif self.operator == "multiply":
self.left *= self.right
self.numbers = str(self.left)
self.value = ""
except Exception:
self.numbers = "Error"
if button_id.startswith("number-"):
number = button_id.partition("-")[-1]
self.numbers = self.value = self.value.lstrip("0") + number
elif button_id == "plus-minus":
self.numbers = self.value = str(Decimal(self.value or "0") * -1)
elif button_id == "percent":
self.numbers = self.value = str(Decimal(self.value or "0") / Decimal(100))
elif button_id == "point":
if "." not in self.value:
self.numbers = self.value = (self.value or "0") + "."
elif button_id == "ac":
self.value = ""
self.left = self.right = Decimal(0)
self.operator = "plus"
self.numbers = "0"
elif button_id == "c":
self.value = ""
self.numbers = "0"
elif button_id in ("plus", "minus", "divide", "multiply"):
self.right = Decimal(self.value or "0")
do_math()
self.operator = button_id
elif button_id == "equals":
if self.value:
self.right = Decimal(self.value)
do_math()
app = CalculatorApp(css_path="calculator.css")
if __name__ == "__main__":

View File

@@ -3,7 +3,7 @@ from textual.widgets import Static
class CenterApp(App):
CSS = """
DEFAULT_CSS = """
CenterApp Screen {
layout: center;

View File

@@ -4,7 +4,7 @@ from textual.widgets import Static
class CenterApp(App):
CSS = """
DEFAULT_CSS = """
#sidebar {
dock: left;
@@ -52,4 +52,4 @@ class CenterApp(App):
)
app = CenterApp(log_verbosity=3)
app = CenterApp()

View File

@@ -10,7 +10,7 @@ from textual.widget import Widget
class Box(Widget, can_focus=True):
CSS = "#box {background: blue;}"
DEFAULT_CSS = "#box {background: blue;}"
def render(self) -> RenderableType:
return Panel("Box")

View File

@@ -21,7 +21,7 @@ class NewScreen(Screen):
class ScreenApp(App):
CSS = """
DEFAULT_CSS = """
ScreenApp Screen {
background: #111144;
color: white;

View File

@@ -37,9 +37,7 @@ class ButtonsApp(App[str]):
self.dark = not self.dark
app = ButtonsApp(
log_path="textual.log", css_path="buttons.css", watch_css=True, log_verbosity=3
)
app = ButtonsApp(log_path="textual.log", css_path="buttons.css", watch_css=True)
if __name__ == "__main__":
result = app.run()

View File

@@ -8,6 +8,6 @@ Static {
background: blue 20%;
height: 100%;
margin: 2 4;
min-width: 30;
visibility: hidden;
min-width: 80;
min-height: 40;
}

View File

@@ -5,7 +5,7 @@ from textual.widgets import DirectoryTree
class TreeApp(App):
CSS = """
DEFAULT_CSS = """
Screen {
overflow: auto;

View File

@@ -1,18 +1,99 @@
import inspect
from __future__ import annotations
import inspect
from typing import TYPE_CHECKING
import rich.repr
from rich.console import RenderableType
__all__ = ["log", "panic"]
def log(*args: object, verbosity: int = 0, **kwargs) -> None:
# TODO: There may be an early-out here for when there is no endpoint for logs
from ._log import LogGroup, LogVerbosity, LogSeverity
@rich.repr.auto
class Logger:
"""A Textual logger."""
def __init__(
self,
group: LogGroup = LogGroup.INFO,
verbosity: LogVerbosity = LogVerbosity.NORMAL,
severity: LogSeverity = LogSeverity.NORMAL,
) -> None:
self._group = group
self._verbosity = verbosity
self._severity = severity
def __rich_repr__(self) -> rich.repr.Result:
yield self._group, LogGroup.INFO
yield self._verbosity, LogVerbosity.NORMAL
yield self._severity, LogSeverity.NORMAL
def __call__(self, *args: object, **kwargs) -> None:
from ._context import active_app
app = active_app.get()
caller = inspect.stack()[1]
app.log(*args, verbosity=verbosity, _textual_calling_frame=caller, **kwargs)
app._log(
self._group,
self._verbosity,
self._severity,
*args,
_textual_calling_frame=caller,
**kwargs,
)
def verbosity(self, verbose: bool) -> Logger:
"""Get a new logger with selective verbosity.
Args:
verbose (bool): True to use HIGH verbosity, otherwise NORMAL.
Returns:
Logger: New logger.
"""
verbosity = LogVerbosity.HIGH if verbose else LogVerbosity.NORMAL
return Logger(self._group, verbosity, LogSeverity.NORMAL)
@property
def verbose(self) -> Logger:
"""A verbose logger."""
return Logger(self._group, LogVerbosity.HIGH)
@property
def critical(self) -> Logger:
"""A critical logger."""
return Logger(self._group, self._verbosity, LogSeverity.CRITICAL)
@property
def event(self) -> Logger:
"""An event logger."""
return Logger(LogGroup.EVENT)
@property
def debug(self) -> Logger:
"""A debug logger."""
return Logger(LogGroup.DEBUG)
@property
def info(self) -> Logger:
"""An info logger."""
return Logger(LogGroup.INFO)
@property
def warning(self) -> Logger:
"""An info logger."""
return Logger(LogGroup.WARNING)
@property
def error(self) -> Logger:
"""An error logger."""
return Logger(LogGroup.ERROR)
log = Logger()
def panic(*args: RenderableType) -> None:

27
src/textual/_log.py Normal file
View File

@@ -0,0 +1,27 @@
from enum import Enum, auto
class LogGroup(Enum):
"""A log group is a classification of the log message (*not* a level)."""
UNDEFINED = 0 # Mainly for testing
EVENT = 1
DEBUG = 2
INFO = 3
WARNING = 4
ERROR = 5
PRINT = 6
class LogVerbosity(Enum):
"""Tags log messages as being verbose and potentially excluded from output."""
NORMAL = 0
HIGH = 1
class LogSeverity(Enum):
"""Tags log messages as being more severe."""
NORMAL = 0
CRITICAL = 1 # Draws attention to the log message

View File

@@ -7,7 +7,7 @@ import os
import platform
import sys
import warnings
from contextlib import redirect_stdout, redirect_stderr
from contextlib import redirect_stderr, redirect_stdout
from datetime import datetime
from pathlib import PurePath, Path
from time import perf_counter
@@ -40,7 +40,16 @@ from rich.protocol import is_renderable
from rich.segment import Segments
from rich.traceback import Traceback
from . import actions, events, log, messages
from . import (
Logger,
LogSeverity,
LogGroup,
LogVerbosity,
actions,
events,
log,
messages,
)
from ._animator import Animator
from ._callback import invoke
from ._context import active_app
@@ -134,7 +143,6 @@ class App(Generic[ReturnType], DOMNode):
Args:
driver_class (Type[Driver] | None, optional): Driver class or ``None`` to auto-detect. Defaults to None.
log_path (str | PurePath, optional): Path to log file, or "" to disable. Defaults to "".
log_verbosity (int, optional): Log verbosity from 0-3. Defaults to 1.
title (str | None, optional): Title of the application. If ``None``, the title is set to the name of the ``App`` subclass. Defaults to ``None``.
css_path (str | PurePath | None, optional): Path to CSS or ``None`` for no CSS file. Defaults to None.
@@ -142,7 +150,11 @@ class App(Generic[ReturnType], DOMNode):
"""
CSS = """
# Inline CSS for quick scripts (generally css_path should be preferred.)
CSS = ""
# Default (lowest priority) CSS
DEFAULT_CSS = """
App {
background: $background;
color: $text-background;
@@ -156,11 +168,6 @@ class App(Generic[ReturnType], DOMNode):
def __init__(
self,
driver_class: Type[Driver] | None = None,
log_path: str | PurePath = "",
log_verbosity: int = 0,
log_color_system: Literal[
"auto", "standard", "256", "truecolor", "windows"
] = "auto",
title: str | None = None,
css_path: str | PurePath | None = None,
watch_css: bool = False,
@@ -201,20 +208,7 @@ class App(Generic[ReturnType], DOMNode):
else:
self._title = title
self._log_console: Console | None = None
self._log_file: TextIO | None = None
if log_path:
self._log_file = open(log_path, "wt")
self._log_console = Console(
file=self._log_file,
color_system=log_color_system,
markup=False,
emoji=False,
highlight=False,
width=100,
)
self.log_verbosity = log_verbosity
self._logger = Logger()
self.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False)
self._refresh_required = False
@@ -486,10 +480,16 @@ class App(Generic[ReturnType], DOMNode):
"""
return Size(*self.console.size)
def log(
@property
def log(self) -> Logger:
return self._logger
def _log(
self,
group: LogGroup,
verbosity: LogVerbosity,
severity: LogSeverity,
*objects: Any,
verbosity: int = 1,
_textual_calling_frame: inspect.FrameInfo | None = None,
**kwargs,
) -> None:
@@ -508,21 +508,23 @@ class App(Generic[ReturnType], DOMNode):
Args:
verbosity (int, optional): Verbosity level 0-3. Defaults to 1.
"""
if verbosity > self.log_verbosity:
return
if self._log_console is None and not self.devtools.is_connected:
if not self.devtools.is_connected:
return
if self.devtools.is_connected and not _textual_calling_frame:
if verbosity.value > LogVerbosity.NORMAL.value and not self.devtools.verbose:
return
if not _textual_calling_frame:
_textual_calling_frame = inspect.stack()[1]
try:
if len(objects) == 1 and not kwargs:
if self._log_console is not None:
self._log_console.print(objects[0])
if self.devtools.is_connected:
self.devtools.log(
DevtoolsLog(objects, caller=_textual_calling_frame)
DevtoolsLog(objects, caller=_textual_calling_frame),
group,
verbosity,
severity,
)
else:
output = " ".join(str(arg) for arg in objects)
@@ -531,11 +533,11 @@ class App(Generic[ReturnType], DOMNode):
f"{key}={value!r}" for key, value in kwargs.items()
)
output = f"{output} {key_values}" if output else key_values
if self._log_console is not None:
self._log_console.print(output, soft_wrap=True)
if self.devtools.is_connected:
self.devtools.log(
DevtoolsLog(output, caller=_textual_calling_frame)
DevtoolsLog(output, caller=_textual_calling_frame),
group,
verbosity,
severity,
)
except Exception as error:
self.on_exception(error)
@@ -683,7 +685,6 @@ class App(Generic[ReturnType], DOMNode):
async def _on_css_change(self) -> None:
"""Called when the CSS changes (if watch_css is True)."""
if self.css_path is not None:
try:
time = perf_counter()
stylesheet = self.stylesheet.copy()
@@ -693,8 +694,8 @@ class App(Generic[ReturnType], DOMNode):
self.log(f"<stylesheet> loaded {self.css_path!r} in {elapsed:.0f} ms")
except Exception as error:
# TODO: Catch specific exceptions
self.log.error(error)
self.bell()
self.log(error)
else:
self.stylesheet = stylesheet
self.reset_styles()
@@ -1042,14 +1043,13 @@ class App(Generic[ReturnType], DOMNode):
if self.devtools_enabled:
try:
await self.devtools.connect()
self.log(f"Connected to devtools ({self.devtools.url})")
self.log(f"Connected to devtools ( {self.devtools.url} )")
except DevtoolsConnectionError:
self.log(f"Couldn't connect to devtools ({self.devtools.url})")
self.log(f"Couldn't connect to devtools ( {self.devtools.url} )")
self.log("---")
self.log(driver=self.driver_class)
self.log(log_verbosity=self.log_verbosity)
self.log(loop=asyncio.get_running_loop())
self.log(features=self.features)
@@ -1060,13 +1060,23 @@ class App(Generic[ReturnType], DOMNode):
self.stylesheet.add_source(
css, path=path, is_default_css=True, tie_breaker=tie_breaker
)
if self.CSS:
try:
app_css_path = (
f"{inspect.getfile(self.__class__)}:{self.__class__.__name__}"
)
except TypeError:
app_css_path = f"{self.__class__.__name__}"
self.stylesheet.add_source(
self.CSS, path=app_css_path, is_default_css=False
)
except Exception as error:
self.on_exception(error)
self._print_error_renderables()
return
if self.css_monitor:
self.set_interval(0.5, self.css_monitor, name="css monitor")
self.set_interval(0.25, self.css_monitor, name="css monitor")
self.log("[b green]STARTED[/]", self.css_monitor)
process_messages = super()._process_messages
@@ -1103,7 +1113,7 @@ class App(Generic[ReturnType], DOMNode):
if self.is_headless:
await run_process_messages()
else:
redirector = StdoutRedirector(self.devtools, self._log_file)
redirector = StdoutRedirector(self.devtools)
with redirect_stderr(redirector):
with redirect_stdout(redirector): # type: ignore
await run_process_messages()
@@ -1116,13 +1126,6 @@ class App(Generic[ReturnType], DOMNode):
self._print_error_renderables()
if self.devtools.is_connected:
await self._disconnect_devtools()
if self._log_console is not None:
self._log_console.print(
f"Disconnected from devtools ({self.devtools.url})"
)
if self._log_file is not None:
self._log_file.close()
self._log_console = None
async def _ready(self) -> None:
"""Called immediately prior to processing messages.

View File

@@ -21,14 +21,16 @@ def run():
@run.command(help="Run the Textual Devtools console.")
def console():
@click.option("-v", "verbose", help="Enable verbose logs.", is_flag=True)
@click.option("-x", "--exclude", "exclude", help="Exclude log group(s)", multiple=True)
def console(verbose: bool, exclude: list[str]) -> None:
from rich.console import Console
console = Console()
console.clear()
console.show_cursor(False)
try:
_run_devtools()
_run_devtools(verbose=verbose, exclude=exclude)
finally:
console.show_cursor(True)

View File

@@ -347,7 +347,7 @@ def parse(
path (str): Path to the CSS
variables (dict[str, str]): Substitution variables to substitute tokens for.
is_default_rules (bool): True if the rules we're extracting are
default (i.e. in Widget.CSS) rules. False if they're from user defined CSS.
default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS.
"""
variable_tokens = tokenize_values(variables or {})
tokens = iter(substitute_references(tokenize(css, path), variable_tokens))

View File

@@ -571,7 +571,7 @@ class Styles(StylesBase):
Args:
specificity (Specificity3): A node specificity.
is_default_rules (bool): True if the rules we're extracting are
default (i.e. in Widget.CSS) rules. False if they're from user defined CSS.
default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS.
Returns:
list[tuple[str, Specificity5, Any]]]: A list containing a tuple of <RULE NAME>, <SPECIFICITY> <RULE VALUE>.

View File

@@ -209,7 +209,7 @@ class Stylesheet:
css (str): String containing Textual CSS.
path (str | PurePath): Path to CSS or unique identifier
is_default_rules (bool): True if the rules we're extracting are
default (i.e. in Widget.CSS) rules. False if they're from user defined CSS.
default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS.
Raises:
StylesheetError: If the CSS is invalid.
@@ -555,7 +555,7 @@ if __name__ == "__main__":
print(app.tree)
print()
CSS = """
DEFAULT_CSS = """
App > View {
layout: dock;
docks: sidebar=left | widgets=top;

View File

@@ -14,7 +14,7 @@ Where the fear has gone there will be nothing. Only I will remain."""
class BorderButtons(layout.Vertical):
CSS = """
DEFAULT_CSS = """
BorderButtons {
dock: left;
width: 24;
@@ -34,7 +34,7 @@ class BorderButtons(layout.Vertical):
class BorderApp(App):
"""Demonstrates the border styles."""
CSS = """
DEFAULT_CSS = """
Static {
margin: 2 4;
padding: 2 4;

View File

@@ -13,6 +13,8 @@ from typing import Type, Any, NamedTuple
from rich.console import Console
from rich.segment import Segment
from .._log import LogGroup, LogVerbosity, LogSeverity
class DevtoolsDependenciesMissingError(Exception):
"""Raise when the required devtools dependencies are not installed in the environment"""
@@ -115,6 +117,7 @@ class DevtoolsClient:
self.websocket: ClientWebSocketResponse | None = None
self.log_queue: Queue[str | bytes | Type[ClientShutdown]] | None = None
self.spillover: int = 0
self.verbose: bool = False
async def connect(self) -> None:
"""Connect to the devtools server.
@@ -136,7 +139,7 @@ class DevtoolsClient:
log_queue = self.log_queue
websocket = self.websocket
async def update_console():
async def update_console() -> None:
"""Coroutine function scheduled as a Task, which listens on
the websocket for updates from the server regarding any changes
in the server Console dimensions. When the client learns of this
@@ -150,6 +153,7 @@ class DevtoolsClient:
payload = message_json["payload"]
self.console.width = payload["width"]
self.console.height = payload["height"]
self.verbose = payload.get("verbose", False)
async def send_queued_logs():
"""Coroutine function which is scheduled as a Task, which consumes
@@ -209,7 +213,13 @@ class DevtoolsClient:
return False
return not (self.session.closed or self.websocket.closed)
def log(self, log: DevtoolsLog) -> None:
def log(
self,
log: DevtoolsLog,
group: LogGroup = LogGroup.UNDEFINED,
verbosity: LogVerbosity = LogVerbosity.NORMAL,
severity: LogSeverity = LogSeverity.NORMAL,
) -> None:
"""Queue a log to be sent to the devtools server for display.
Args:
@@ -227,6 +237,9 @@ class DevtoolsClient:
{
"type": "client_log",
"payload": {
"group": group.value,
"verbosity": verbosity.value,
"severity": severity.value,
"timestamp": int(time()),
"path": getattr(log.caller, "filename", ""),
"line_number": getattr(log.caller, "lineno", 0),

View File

@@ -1,12 +1,13 @@
from __future__ import annotations
import inspect
from io import TextIOWrapper
from typing import TYPE_CHECKING, cast
from textual.devtools.client import DevtoolsLog
from .client import DevtoolsLog
from .._log import LogGroup, LogVerbosity, LogSeverity
if TYPE_CHECKING:
from textual.devtools.client import DevtoolsClient
from .devtools.client import DevtoolsClient
class StdoutRedirector:
@@ -17,16 +18,13 @@ class StdoutRedirector:
log file.
"""
def __init__(
self, devtools: DevtoolsClient, log_file: TextIOWrapper | None
) -> None:
def __init__(self, devtools: DevtoolsClient) -> None:
"""
Args:
devtools (DevtoolsClient): The running Textual app instance.
log_file (TextIOWrapper): The log file for the Textual App.
"""
self.devtools = devtools
self.log_file = log_file
self._buffer: list[DevtoolsLog] = []
def write(self, string: str) -> None:
@@ -38,7 +36,7 @@ class StdoutRedirector:
string (str): The string to write to the buffer.
"""
if not (self.devtools.is_connected or self.log_file is not None):
if not self.devtools.is_connected:
return
caller = inspect.stack()[1]
@@ -59,7 +57,6 @@ class StdoutRedirector:
the devtools server and the log file. In the case of the devtools,
where possible, log messages will be batched and sent as one.
"""
self._write_to_log_file()
self._write_to_devtools()
self._buffer.clear()
@@ -81,20 +78,6 @@ class StdoutRedirector:
if log_batch:
self._log_devtools_batched(log_batch)
def _write_to_log_file(self) -> None:
"""Write the contents of the buffer to the log file."""
if not self.log_file:
return
try:
log_text = "".join(str(log.objects_or_string) for log in self._buffer)
self.log_file.write(log_text)
self.log_file.flush()
except OSError:
# An error writing to the log file should not be
# considered fatal.
pass
def _log_devtools_batched(self, log_batch: list[DevtoolsLog]) -> None:
"""Write a single batch of logs to devtools. A batch means contiguous logs
which have been written from the same line number and file path.
@@ -114,4 +97,9 @@ class StdoutRedirector:
# that the log message content is a string. The cast below tells mypy this.
batched_log = "".join(cast(str, log.objects_or_string) for log in log_batch)
batched_log = batched_log.rstrip()
self.devtools.log(DevtoolsLog(batched_log, caller=log_batch[-1].caller))
self.devtools.log(
DevtoolsLog(batched_log, caller=log_batch[-1].caller),
LogGroup.PRINT,
LogVerbosity.NORMAL,
LogSeverity.NORMAL,
)

View File

@@ -18,15 +18,18 @@ from rich.markup import escape
from rich.rule import Rule
from rich.segment import Segment, Segments
from rich.style import Style
from rich.styled import Styled
from rich.table import Table
from rich.text import Text
from textual._border import Border
from textual._log import LogGroup
DevConsoleMessageLevel = Literal["info", "warning", "error"]
class DevConsoleHeader:
def __init__(self, verbose: bool = False) -> None:
self.verbose = verbose
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
@@ -35,6 +38,8 @@ class DevConsoleHeader:
"[magenta]Run a Textual app with [reverse]textual run --dev my_app.py[/] to connect.\n"
"[magenta]Press [reverse]Ctrl+C[/] to quit."
)
if self.verbose:
preamble.append(Text.from_markup("\n[cyan]Verbose logs enabled"))
render_options = options.update(width=options.max_width - 4)
lines = console.render_lines(preamble, render_options)
@@ -63,11 +68,17 @@ class DevConsoleLog:
path: str,
line_number: int,
unix_timestamp: int,
group: int,
verbosity: int,
severity: int,
) -> None:
self.segments = segments
self.path = path
self.line_number = line_number
self.unix_timestamp = unix_timestamp
self.group = group
self.verbosity = verbosity
self.severity = severity
def __rich_console__(
self, console: Console, options: ConsoleOptions
@@ -77,13 +88,25 @@ class DevConsoleLog:
file_link = escape(f"file://{Path(self.path).absolute()}")
file_and_line = escape(f"{Path(self.path).name}:{self.line_number}")
group = LogGroup(self.group).name
time = local_time.time()
message = Text(
f":warning-emoji: [{time}] {group}"
if self.severity > 0
else f"[{time}] {group}"
)
message.stylize("dim")
table.add_row(
f"[dim]{local_time.time()}",
message,
Align.right(
Text(f"{file_and_line}", style=Style(dim=True, link=file_link))
),
)
yield table
if group == "PRINT":
yield Styled(Segments(self.segments), "bold")
else:
yield Segments(self.segments)

View File

@@ -36,8 +36,8 @@ async def _on_startup(app: Application) -> None:
await service.start()
def _run_devtools() -> None:
app = _make_devtools_aiohttp_app()
def _run_devtools(verbose: bool, exclude: list[str] | None = None) -> None:
app = _make_devtools_aiohttp_app(verbose=verbose, exclude=exclude)
def noop_print(_: str):
return None
@@ -47,14 +47,17 @@ def _run_devtools() -> None:
def _make_devtools_aiohttp_app(
size_change_poll_delay_secs: float = DEFAULT_SIZE_CHANGE_POLL_DELAY_SECONDS,
verbose: bool = False,
exclude: list[str] | None = None,
) -> Application:
app = Application()
app.on_shutdown.append(_on_shutdown)
app.on_startup.append(_on_startup)
app["verbose"] = verbose
app["service"] = DevtoolsService(
update_frequency=size_change_poll_delay_secs,
update_frequency=size_change_poll_delay_secs, verbose=verbose, exclude=exclude
)
app.add_routes(

View File

@@ -16,6 +16,7 @@ from rich.console import Console
from rich.markup import escape
import msgpack
from textual._log import LogGroup
from textual.devtools.renderables import (
DevConsoleLog,
DevConsoleNotice,
@@ -30,13 +31,22 @@ class DevtoolsService:
responsible for tracking connected client applications.
"""
def __init__(self, update_frequency: float) -> None:
def __init__(
self,
update_frequency: float,
verbose: bool = False,
exclude: list[str] | None = None,
) -> None:
"""
Args:
update_frequency (float): The number of seconds to wait between
sending updates of the console size to connected clients.
verbose (bool): Enable verbose logging on client.
exclude (list[str]): List of log groups to exclude from output.
"""
self.update_frequency = update_frequency
self.verbose = verbose
self.exclude = set(name.upper() for name in exclude) if exclude else set()
self.console = Console()
self.shutdown_event = asyncio.Event()
self.clients: list[ClientHandler] = []
@@ -44,7 +54,7 @@ class DevtoolsService:
async def start(self):
"""Starts devtools tasks"""
self.size_poll_task = asyncio.create_task(self._console_size_poller())
self.console.print(DevConsoleHeader())
self.console.print(DevConsoleHeader(verbose=self.verbose))
@property
def clients_connected(self) -> bool:
@@ -58,6 +68,7 @@ class DevtoolsService:
"""
current_width = self.console.width
current_height = self.console.height
await self._send_server_info_to_all()
while not self.shutdown_event.is_set():
width = self.console.width
height = self.console.height
@@ -91,6 +102,7 @@ class DevtoolsService:
"payload": {
"width": self.console.width,
"height": self.console.height,
"verbose": self.verbose,
},
}
)
@@ -168,10 +180,10 @@ class ClientHandler:
type = message["type"]
if type == "client_log":
path = message["payload"]["path"]
line_number = message["payload"]["line_number"]
timestamp = message["payload"]["timestamp"]
encoded_segments = message["payload"]["segments"]
payload = message["payload"]
if LogGroup(payload.get("group", 0)).name in self.service.exclude:
continue
encoded_segments = payload["segments"]
segments = pickle.loads(encoded_segments)
message_time = time()
if (
@@ -183,9 +195,12 @@ class ClientHandler:
self.service.console.print(
DevConsoleLog(
segments=segments,
path=path,
line_number=line_number,
unix_timestamp=timestamp,
path=payload["path"],
line_number=payload["line_number"],
unix_timestamp=payload["timestamp"],
group=payload.get("group", 0),
verbosity=payload.get("verbosity", 0),
severity=payload.get("severity", 0),
)
)
last_message_time = message_time

View File

@@ -54,8 +54,8 @@ class NoParent(Exception):
class DOMNode(MessagePump):
"""The base class for object that can be in the Textual DOM (App and Widget)"""
# Custom CSS
CSS: ClassVar[str] = ""
# CSS defaults
DEFAULT_CSS: ClassVar[str] = ""
# Default classes argument if not supplied
DEFAULT_CLASSES: str = ""
@@ -198,7 +198,7 @@ class DOMNode(MessagePump):
return f"{base.__name__}"
for tie_breaker, base in enumerate(self._node_bases):
css = base.CSS.strip()
css = base.DEFAULT_CSS.strip()
if css:
css_stack.append((get_path(base), css, -tie_breaker))

View File

@@ -19,17 +19,17 @@ if TYPE_CHECKING:
@rich.repr.auto
class Event(Message, verbosity=2):
class Event(Message):
def __rich_repr__(self) -> rich.repr.Result:
return
yield
def __init_subclass__(cls, bubble: bool = True, verbosity: int = 1) -> None:
super().__init_subclass__(bubble=bubble, verbosity=verbosity)
def __init_subclass__(cls, bubble: bool = True, verbose: bool = False) -> None:
super().__init_subclass__(bubble=bubble, verbose=verbose)
@rich.repr.auto
class Callback(Event, bubble=False, verbosity=3):
class Callback(Event, bubble=False, verbose=True):
def __init__(
self, sender: MessageTarget, callback: Callable[[], Awaitable[None]]
) -> None:
@@ -40,7 +40,7 @@ class Callback(Event, bubble=False, verbosity=3):
yield "callback", self.callback
class InvokeCallbacks(Event, bubble=False):
class InvokeCallbacks(Event, bubble=False, verbose=True):
"""Sent after the Screen is updated"""
@@ -83,7 +83,7 @@ class Action(Event):
yield "action", self.action
class Resize(Event, verbosity=2, bubble=False):
class Resize(Event, bubble=False):
"""Sent when the app or widget has been resized.
Args:
sender (MessageTarget): The sender of the event (the Screen).
@@ -116,14 +116,10 @@ class Resize(Event, verbosity=2, bubble=False):
yield "container_size", self.container_size, self.size
class Mount(Event, bubble=False):
class Mount(Event, bubble=False, verbose=True):
"""Sent when a widget is *mounted* and may receive messages."""
class Unmount(Event, bubble=False):
"""Sent when a widget is unmounted, and may no longer receive messages."""
class Remove(Event, bubble=False):
"""Sent to a widget to ask it to remove itself from the DOM."""
@@ -217,7 +213,7 @@ class Key(InputEvent):
@rich.repr.auto
class MouseEvent(InputEvent, bubble=True, verbosity=2):
class MouseEvent(InputEvent, bubble=True):
"""Sent in response to a mouse event.
Args:
@@ -336,21 +332,21 @@ class MouseEvent(InputEvent, bubble=True, verbosity=2):
@rich.repr.auto
class MouseMove(MouseEvent, verbosity=3, bubble=True):
class MouseMove(MouseEvent, bubble=True, verbose=True):
"""Sent when the mouse cursor moves."""
@rich.repr.auto
class MouseDown(MouseEvent, bubble=True):
class MouseDown(MouseEvent, bubble=True, verbose=True):
pass
@rich.repr.auto
class MouseUp(MouseEvent, bubble=True):
class MouseUp(MouseEvent, bubble=True, verbose=True):
pass
class MouseScrollDown(InputEvent, verbosity=3, bubble=True):
class MouseScrollDown(InputEvent, bubble=True, verbose=True):
__slots__ = ["x", "y"]
def __init__(self, sender: MessageTarget, x: int, y: int) -> None:
@@ -359,7 +355,7 @@ class MouseScrollDown(InputEvent, verbosity=3, bubble=True):
self.y = y
class MouseScrollUp(InputEvent, verbosity=3, bubble=True):
class MouseScrollUp(InputEvent, bubble=True, verbose=True):
__slots__ = ["x", "y"]
def __init__(self, sender: MessageTarget, x: int, y: int) -> None:
@@ -373,7 +369,7 @@ class Click(MouseEvent, bubble=True):
@rich.repr.auto
class Timer(Event, verbosity=3, bubble=False):
class Timer(Event, bubble=False, verbose=True):
__slots__ = ["time", "count", "callback"]
def __init__(
@@ -395,11 +391,11 @@ class Timer(Event, verbosity=3, bubble=False):
yield "count", self.count
class Enter(Event, bubble=False):
class Enter(Event, bubble=False, verbose=True):
pass
class Leave(Event, bubble=False):
class Leave(Event, bubble=False, verbose=True):
pass
@@ -411,11 +407,11 @@ class Blur(Event, bubble=False):
pass
class DescendantFocus(Event, verbosity=2, bubble=True):
class DescendantFocus(Event, bubble=True, verbose=True):
pass
class DescendantBlur(Event, verbosity=2, bubble=True):
class DescendantBlur(Event, bubble=True, verbose=True):
pass

View File

@@ -4,7 +4,7 @@ from .widget import Widget
class Container(Widget):
"""Simple container widget, with vertical layout."""
CSS = """
DEFAULT_CSS = """
Container {
layout: vertical;
overflow: auto;
@@ -16,13 +16,13 @@ class Vertical(Container):
"""A container widget to align children vertically."""
# Blank CSS is important, otherwise you get a clone of Container
CSS = ""
DEFAULT_CSS = ""
class Horizontal(Container):
"""A container widget to align children horizontally."""
CSS = """
DEFAULT_CSS = """
Horizontal {
layout: horizontal;
}
@@ -32,7 +32,7 @@ class Horizontal(Container):
class Center(Container):
"""A container widget to align children in the center."""
CSS = """
DEFAULT_CSS = """
Center {
layout: center;
}

View File

@@ -30,7 +30,7 @@ class Message:
sender: MessageTarget
bubble: ClassVar[bool] = True # Message will bubble to parent
verbosity: ClassVar[int] = 1 # Verbosity (higher the more verbose)
verbose: ClassVar[bool] = False # Message is verbose
no_dispatch: ClassVar[bool] = False # Message may not be handled by client code
namespace: ClassVar[str] = "" # Namespace to disambiguate messages
@@ -52,15 +52,14 @@ class Message:
def __init_subclass__(
cls,
bubble: bool | None = True,
verbosity: int | None = 1,
verbose: bool = False,
no_dispatch: bool | None = False,
namespace: str | None = None,
) -> None:
super().__init_subclass__()
if bubble is not None:
cls.bubble = bubble
if verbosity is not None:
cls.verbosity = verbosity
cls.verbose = verbose
if no_dispatch is not None:
cls.no_dispatch = no_dispatch
if namespace is not None:

View File

@@ -15,7 +15,7 @@ from functools import partial
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterable
from weakref import WeakSet
from . import events, log, messages
from . import events, log, messages, Logger
from ._callback import invoke
from ._context import NoActiveAppError, active_app
from .timer import Timer, TimerCallback
@@ -110,33 +110,9 @@ class MessagePump(metaclass=MessagePumpMeta):
def is_running(self) -> bool:
return self._running
def log(
self,
*args: Any,
verbosity: int = 1,
**kwargs,
) -> None:
"""Write to logs or devtools.
Positional args will logged. Keyword args will be prefixed with the key.
Example:
```python
data = [1,2,3]
self.log("Hello, World", state=data)
self.log(self.tree)
self.log(locals())
```
Args:
verbosity (int, optional): Verbosity level 0-3. Defaults to 1.
"""
return self.app.log(
*args,
**kwargs,
verbosity=verbosity,
_textual_calling_frame=inspect.stack()[1],
)
@property
def log(self) -> Logger:
return self.app._logger
def _attach(self, parent: MessagePump) -> None:
"""Set the parent, and therefore attach this node to the tree.
@@ -422,12 +398,11 @@ class MessagePump(metaclass=MessagePumpMeta):
# Look through the MRO to find a handler
for cls, method in self._get_dispatch_methods(handler_name, message):
log(
log.event.verbosity(message.verbose)(
message,
">>>",
self,
f"method=<{cls.__name__}.{handler_name}>",
verbosity=message.verbosity,
)
await invoke(method, message)

View File

@@ -13,7 +13,7 @@ if TYPE_CHECKING:
@rich.repr.auto
class Update(Message, verbosity=3):
class Update(Message, verbose=True):
def __init__(self, sender: MessagePump, widget: Widget):
super().__init__(sender)
self.widget = widget
@@ -33,13 +33,13 @@ class Update(Message, verbosity=3):
@rich.repr.auto
class Layout(Message, verbosity=3):
class Layout(Message, verbose=True):
def can_replace(self, message: Message) -> bool:
return isinstance(message, Layout)
@rich.repr.auto
class InvokeLater(Message, verbosity=3):
class InvokeLater(Message, verbose=True):
def __init__(self, sender: MessagePump, callback: CallbackType) -> None:
self.callback = callback
super().__init__(sender)

View File

@@ -29,7 +29,7 @@ UPDATE_PERIOD: Final = 1 / 60
class Screen(Widget):
"""A widget for the root of the app."""
CSS = """
DEFAULT_CSS = """
Screen {
layout: vertical;
overflow-y: auto;

View File

@@ -16,7 +16,7 @@ class ScrollView(Widget):
"""
CSS = """
DEFAULT_CSS = """
ScrollView {
overflow-y: auto;
@@ -87,15 +87,19 @@ class ScrollView(Widget):
width, height = self.container_size
if self.show_vertical_scrollbar:
self.vertical_scrollbar.window_virtual_size = virtual_size.height
self.vertical_scrollbar.window_size = height
self.vertical_scrollbar.window_size = (
height - self.scrollbar_size_horizontal
)
if self.show_horizontal_scrollbar:
self.horizontal_scrollbar.window_virtual_size = virtual_size.width
self.horizontal_scrollbar.window_size = width
self.horizontal_scrollbar.window_size = (
width - self.scrollbar_size_vertical
)
self.scroll_x = self.validate_scroll_x(self.scroll_x)
self.scroll_y = self.validate_scroll_y(self.scroll_y)
self.refresh(layout=False)
self.call_later(self.scroll_to, self.scroll_x, self.scroll_y)
self.scroll_to(self.scroll_x, self.scroll_y)
def render(self) -> RenderableType:
"""Render the scrollable region (if `render_lines` is not implemented).

View File

@@ -22,26 +22,26 @@ class ScrollMessage(Message, bubble=False):
@rich.repr.auto
class ScrollUp(ScrollMessage):
class ScrollUp(ScrollMessage, verbose=True):
"""Message sent when clicking above handle."""
@rich.repr.auto
class ScrollDown(ScrollMessage):
class ScrollDown(ScrollMessage, verbose=True):
"""Message sent when clicking below handle."""
@rich.repr.auto
class ScrollLeft(ScrollMessage):
class ScrollLeft(ScrollMessage, verbose=True):
"""Message sent when clicking above handle."""
@rich.repr.auto
class ScrollRight(ScrollMessage):
class ScrollRight(ScrollMessage, verbose=True):
"""Message sent when clicking below handle."""
class ScrollTo(ScrollMessage):
class ScrollTo(ScrollMessage, verbose=True):
"""Message sent when click and dragging handle."""
def __init__(
@@ -93,9 +93,9 @@ class ScrollBarRender:
) -> Segments:
if vertical:
bars = [" ", "", "", "", "", "", "", "", ""]
bars = ["", "", "", "", "", "", "", " "]
else:
bars = ["", "", "", "", "", "", "", "", " "]
bars = ["", "", "", "", "", "", "", " "]
back = back_color
bar = bar_color
@@ -110,11 +110,11 @@ class ScrollBarRender:
if window_size and size and virtual_size and size != virtual_size:
step_size = virtual_size / size
start = int(position / step_size * 9)
end = start + max(9, int(ceil(window_size / step_size * 9)))
start = int(position / step_size * 8)
end = start + max(8, int(ceil(window_size / step_size * 8)))
start_index, start_bar = divmod(start, 9)
end_index, end_bar = divmod(end, 9)
start_index, start_bar = divmod(start, 8)
end_index, end_bar = divmod(end, 8)
upper = {"@click": "scroll_up"}
lower = {"@click": "scroll_down"}
@@ -130,15 +130,19 @@ class ScrollBarRender:
] * (end_index - start_index)
if start_index < len(segments):
bar_character = bars[7 - start_bar]
if bar_character != " ":
segments[start_index] = _Segment(
bars[8 - start_bar] * width_thickness,
bar_character * width_thickness,
_Style(bgcolor=back, color=bar, meta=foreground_meta)
if vertical
else _Style(bgcolor=bar, color=back, meta=foreground_meta),
)
if end_index < len(segments):
bar_character = bars[7 - end_bar]
if bar_character != " ":
segments[end_index] = _Segment(
bars[8 - end_bar] * width_thickness,
bar_character * width_thickness,
_Style(bgcolor=bar, color=back, meta=foreground_meta)
if vertical
else _Style(bgcolor=back, color=bar, meta=foreground_meta),

View File

@@ -72,7 +72,7 @@ class Widget(DOMNode):
"""
CSS = """
DEFAULT_CSS = """
Widget{
scrollbar-background: $panel-darken-1;
scrollbar-background-hover: $panel-darken-2;
@@ -1248,16 +1248,19 @@ class Widget(DOMNode):
width, height = self.container_size
if self.show_vertical_scrollbar:
self.vertical_scrollbar.window_virtual_size = virtual_size.height
self.vertical_scrollbar.window_size = height
self.vertical_scrollbar.window_size = (
height - self.scrollbar_size_horizontal
)
if self.show_horizontal_scrollbar:
self.horizontal_scrollbar.window_virtual_size = virtual_size.width
self.horizontal_scrollbar.window_size = width
self.horizontal_scrollbar.window_size = (
width - self.scrollbar_size_vertical
)
self.scroll_x = self.validate_scroll_x(self.scroll_x)
self.scroll_y = self.validate_scroll_y(self.scroll_y)
self.refresh(layout=True)
self.scroll_to(self.scroll_x, self.scroll_y)
# self.call_later(self.scroll_to, self.scroll_x, self.scroll_y)
else:
self.refresh()
@@ -1396,7 +1399,7 @@ class Widget(DOMNode):
if not self.check_message_enabled(message):
return True
if not self.is_running:
self.log(self, f"IS NOT RUNNING, {message!r} not sent")
self.log.warning(self, f"IS NOT RUNNING, {message!r} not sent")
return await super().post_message(message)
async def _on_idle(self, event: events.Idle) -> None:

View File

@@ -29,7 +29,7 @@ class InvalidButtonVariant(Exception):
class Button(Widget, can_focus=True):
"""A simple clickable button."""
CSS = """
DEFAULT_CSS = """
Button {
width: auto;
min-width: 10;

View File

@@ -106,7 +106,7 @@ class Coord(NamedTuple):
class DataTable(ScrollView, Generic[CellType], can_focus=True):
CSS = """
DEFAULT_CSS = """
DataTable {
background: $surface;
color: $text-surface;

View File

@@ -13,7 +13,7 @@ from ..widget import Widget
@rich.repr.auto
class Footer(Widget):
CSS = """
DEFAULT_CSS = """
Footer {
background: $accent;
color: $text-accent;

View File

@@ -11,7 +11,7 @@ from ..reactive import Reactive, watch
class HeaderIcon(Widget):
"""Display an 'icon' on the left of the header."""
CSS = """
DEFAULT_CSS = """
HeaderIcon {
dock: left;
padding: 0 1;
@@ -28,7 +28,7 @@ class HeaderIcon(Widget):
class HeaderClock(Widget):
"""Display a clock on the right of the header."""
CSS = """
DEFAULT_CSS = """
HeaderClock {
dock: right;
width: auto;
@@ -50,7 +50,7 @@ class HeaderClock(Widget):
class HeaderTitle(Widget):
"""Display the title / subtitle in the header."""
CSS = """
DEFAULT_CSS = """
HeaderTitle {
content-align: center middle;
width: 100%;
@@ -70,7 +70,7 @@ class HeaderTitle(Widget):
class Header(Widget):
"""A header widget with icon and clock."""
CSS = """
DEFAULT_CSS = """
Header {
dock: top;
width: 100%;

View File

@@ -7,7 +7,7 @@ from ..widget import Widget
class Pretty(Widget):
CSS = """
DEFAULT_CSS = """
Static {
height: auto;
}

View File

@@ -25,7 +25,7 @@ def _check_renderable(renderable: object):
class Static(Widget):
CSS = """
DEFAULT_CSS = """
Static {
height: auto;
}

View File

@@ -169,7 +169,7 @@ class TreeClick(Generic[NodeDataType], Message, bubble=True):
class TreeControl(Generic[NodeDataType], Widget, can_focus=True):
CSS = """
DEFAULT_CSS = """
TreeControl {
background: $panel;
color: $text-panel;

View File

@@ -112,7 +112,7 @@ class TextInput(TextWidgetBase, can_focus=True):
suggestion will be displayed as dim text similar to suggestion text in the zsh or fish shells.
"""
CSS = """
DEFAULT_CSS = """
TextInput {
width: auto;
background: $surface;
@@ -417,7 +417,7 @@ class TextInput(TextWidgetBase, can_focus=True):
class TextArea(Widget):
CSS = """
DEFAULT_CSS = """
TextArea { overflow: auto auto; height: 5; background: $primary-darken-1; }
"""
@@ -428,7 +428,7 @@ class TextArea(Widget):
class TextAreaChild(TextWidgetBase, can_focus=True):
# TODO: Not nearly ready for prime-time, but it exists to help
# model the superclass.
CSS = "TextAreaChild { height: auto; background: $primary-darken-1; }"
DEFAULT_CSS = "TextAreaChild { height: auto; background: $primary-darken-1; }"
STOP_PROPAGATE = {"tab", "shift+tab"}
def render(self) -> RenderableType:

View File

@@ -106,16 +106,18 @@ def test_stylesheet_apply_user_css_over_widget_css():
user_css = ".a {color: red; tint: yellow;}"
class MyWidget(Widget):
CSS = ".a {color: blue !important; background: lime;}"
DEFAULT_CSS = ".a {color: blue !important; background: lime;}"
node = MyWidget()
node.add_class("a")
stylesheet = _make_user_stylesheet(user_css)
stylesheet.add_source(MyWidget.CSS, "widget.py:MyWidget", is_default_css=True)
stylesheet.add_source(
MyWidget.DEFAULT_CSS, "widget.py:MyWidget", is_default_css=True
)
stylesheet.apply(node)
# The node is red because user CSS overrides Widget.CSS
# The node is red because user CSS overrides Widget.DEFAULT_CSS
assert node.styles.color == Color(255, 0, 0)
# The background colour defined in the Widget still applies, since user CSS doesn't override it
assert node.styles.background == Color(0, 255, 0)

View File

@@ -27,8 +27,3 @@ async def devtools(aiohttp_client, server):
yield devtools
await devtools.disconnect()
await client.close()
@pytest.fixture
def in_memory_logfile():
yield StringIO()

View File

@@ -37,6 +37,9 @@ def test_log_message_render(console):
path="abc/hello.py",
line_number=123,
unix_timestamp=TIMESTAMP,
group=0,
verbosity=0,
severity=0,
)
table = next(iter(message.__rich_console__(console, console.options)))
@@ -52,7 +55,7 @@ def test_log_message_render(console):
local_time = datetime.fromtimestamp(TIMESTAMP)
string_timestamp = local_time.time()
assert left == f"[dim]{string_timestamp}"
assert left.plain == f"[{string_timestamp}] UNDEFINED"
assert right.align == "right"
assert "hello.py:123" in right.renderable

View File

@@ -36,15 +36,20 @@ async def test_devtools_log_places_encodes_and_queues_message(devtools):
queued_log = await devtools.log_queue.get()
queued_log_data = msgpack.unpackb(queued_log)
print(repr(queued_log_data))
assert queued_log_data == {
{
"type": "client_log",
"payload": {
"group": 0,
"verbosity": 0,
"severity": 0,
"timestamp": 1649166819,
"path": "a/b/c.py",
"line_number": 123,
"segments": b"\x80\x04\x95B\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\rHello, world!\x94NN\x87\x94\x81\x94h\x03\x8c\x01\n\x94NN\x87\x94\x81\x94e.",
"segments": b"\x80\x04\x95@\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\x0bhello world\x94NN\x87\x94\x81\x94h\x03\x8c\x01\n\x94NN\x87\x94\x81\x94e.",
},
}
}
@time_machine.travel(datetime.utcfromtimestamp(TIMESTAMP))
@@ -57,6 +62,9 @@ async def test_devtools_log_places_encodes_and_queues_many_logs_as_string(devtoo
assert queued_log_data == {
"type": "client_log",
"payload": {
"group": 0,
"verbosity": 0,
"severity": 0,
"timestamp": 1649166819,
"path": "a/b/c.py",
"line_number": 123,

View File

@@ -14,7 +14,7 @@ TIMESTAMP = 1649166819
async def test_print_redirect_to_devtools_only(devtools):
await devtools._stop_log_queue_processing()
with redirect_stdout(StdoutRedirector(devtools, None)): # type: ignore
with redirect_stdout(StdoutRedirector(devtools)): # type: ignore
print("Hello, world!")
assert devtools.log_queue.qsize() == 1
@@ -32,28 +32,26 @@ async def test_print_redirect_to_devtools_only(devtools):
)
async def test_print_redirect_to_logfile_only(devtools, in_memory_logfile):
async def test_print_redirect_to_logfile_only(devtools):
await devtools.disconnect()
with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)): # type: ignore
with redirect_stdout(StdoutRedirector(devtools)): # type: ignore
print("Hello, world!")
assert in_memory_logfile.getvalue() == "Hello, world!\n"
async def test_print_redirect_to_devtools_and_logfile(devtools, in_memory_logfile):
async def test_print_redirect_to_devtools_and_logfile(devtools):
await devtools._stop_log_queue_processing()
with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)): # type: ignore
with redirect_stdout(StdoutRedirector(devtools)): # type: ignore
print("Hello, world!")
assert devtools.log_queue.qsize() == 1
assert in_memory_logfile.getvalue() == "Hello, world!\n"
@time_machine.travel(datetime.fromtimestamp(TIMESTAMP))
async def test_print_without_flush_not_sent_to_devtools(devtools, in_memory_logfile):
async def test_print_without_flush_not_sent_to_devtools(devtools):
await devtools._stop_log_queue_processing()
with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)): # type: ignore
with redirect_stdout(StdoutRedirector(devtools)): # type: ignore
# End is no longer newline character, so print will no longer
# flush the output buffer by default.
print("Hello, world!", end="")
@@ -62,44 +60,19 @@ async def test_print_without_flush_not_sent_to_devtools(devtools, in_memory_logf
@time_machine.travel(datetime.fromtimestamp(TIMESTAMP))
async def test_print_forced_flush_sent_to_devtools(devtools, in_memory_logfile):
async def test_print_forced_flush_sent_to_devtools(devtools):
await devtools._stop_log_queue_processing()
with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)): # type: ignore
with redirect_stdout(StdoutRedirector(devtools)): # type: ignore
print("Hello, world!", end="", flush=True)
assert devtools.log_queue.qsize() == 1
@time_machine.travel(datetime.fromtimestamp(TIMESTAMP))
async def test_print_multiple_args_batched_as_one_log(devtools, in_memory_logfile):
async def test_print_multiple_args_batched_as_one_log(devtools):
await devtools._stop_log_queue_processing()
with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)): # type: ignore
# We call print with multiple arguments here, but it
# results in a single log added to the log queue.
print("Hello", "world", "multiple")
assert devtools.log_queue.qsize() == 1
queued_log = await devtools.log_queue.get()
queued_log_json = json.loads(queued_log)
payload = queued_log_json["payload"]
assert queued_log_json["type"] == "client_log"
assert payload["timestamp"] == TIMESTAMP
assert (
payload["encoded_segments"]
== "gANdcQAoY3JpY2guc2VnbWVudApTZWdtZW50CnEBWBQAAABIZWxsbyB3b3JsZCBtdWx0aXBsZXECTk6HcQOBcQRoAVgBAAAACnEFTk6HcQaBcQdlLg=="
)
assert len(payload["path"]) > 0
assert payload["line_number"] != 0
@time_machine.travel(datetime.fromtimestamp(TIMESTAMP))
async def test_print_multiple_args_batched_as_one_log(devtools, in_memory_logfile):
await devtools._stop_log_queue_processing()
redirector = StdoutRedirector(devtools, in_memory_logfile)
redirector = StdoutRedirector(devtools)
with redirect_stdout(redirector): # type: ignore
# This print adds 3 messages to the buffer that can be batched
print("The first", "batch", "of logs", end="")
@@ -111,10 +84,10 @@ async def test_print_multiple_args_batched_as_one_log(devtools, in_memory_logfil
@time_machine.travel(datetime.fromtimestamp(TIMESTAMP))
async def test_print_strings_containing_newline_flushed(devtools, in_memory_logfile):
async def test_print_strings_containing_newline_flushed(devtools):
await devtools._stop_log_queue_processing()
with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)): # type: ignore
with redirect_stdout(StdoutRedirector(devtools)): # type: ignore
# Flushing is disabled since end="", but the first
# string will be flushed since it contains a newline
print("Hel\nlo", end="")
@@ -124,10 +97,10 @@ async def test_print_strings_containing_newline_flushed(devtools, in_memory_logf
@time_machine.travel(datetime.fromtimestamp(TIMESTAMP))
async def test_flush_flushes_buffered_logs(devtools, in_memory_logfile):
async def test_flush_flushes_buffered_logs(devtools):
await devtools._stop_log_queue_processing()
redirector = StdoutRedirector(devtools, in_memory_logfile)
redirector = StdoutRedirector(devtools)
with redirect_stdout(redirector): # type: ignore
print("x", end="")

View File

@@ -110,7 +110,7 @@ async def test_composition_of_vertical_container_with_children(
expected_placeholders_offset_x: int,
):
class VerticalContainer(Widget):
CSS = (
DEFAULT_CSS = (
"""
VerticalContainer {
layout: vertical;
@@ -304,7 +304,7 @@ async def test_scrollbar_size_impact_on_the_layout(
class LargeWidgetContainer(Widget):
# TODO: Once textual#581 ("Default versus User CSS") is solved the following CSS should just use the
# "LargeWidgetContainer" selector, without having to use a more specific one to be able to override Widget's CSS:
CSS = """
DEFAULT_CSS = """
#large-widget-container {
width: 20;
height: 20;

View File

@@ -48,7 +48,7 @@ async def test_scroll_to_widget(
last_screen_expected_placeholder_ids: Sequence[int],
):
class VerticalContainer(Widget):
CSS = """
DEFAULT_CSS = """
VerticalContainer {
layout: vertical;
overflow: hidden auto;
@@ -60,7 +60,7 @@ async def test_scroll_to_widget(
"""
class MyTestApp(AppTest):
CSS = """
DEFAULT_CSS = """
Placeholder {
height: 5; /* minimal height to see the name of a Placeholder */
}

View File

@@ -25,20 +25,11 @@ from textual.geometry import Size, Region
class AppTest(App):
def __init__(
self,
*,
test_name: str,
size: Size,
log_verbosity: int = 2,
):
def __init__(self, *, test_name: str, size: Size):
# Tests will log in "/tests/test.[test name].log":
log_path = Path(__file__).parent.parent / f"test.{test_name}.log"
super().__init__(
driver_class=DriverTest,
log_path=log_path,
log_verbosity=log_verbosity,
log_color_system="256",
)
# Let's disable all features by default