diff --git a/docs/examples/guide/structure.py b/docs/examples/guide/structure.py index d1766420a..11f285a74 100644 --- a/docs/examples/guide/structure.py +++ b/docs/examples/guide/structure.py @@ -7,7 +7,7 @@ from textual.widget import Widget class Clock(Widget): """A clock app.""" - CSS = """ + DEFAULT_CSS = """ Clock { content-align: center middle; } diff --git a/docs/examples/light_dark.py b/docs/examples/light_dark.py index 5e2e9229f..be4351258 100644 --- a/docs/examples/light_dark.py +++ b/docs/examples/light_dark.py @@ -4,7 +4,7 @@ from textual.widgets import Button class ButtonApp(App): - CSS = """ + DEFAULT_CSS = """ Button { width: 100%; } diff --git a/old examples/animation.py b/old examples/animation.py index 0a6efaf19..1fbff9e9d 100644 --- a/old examples/animation.py +++ b/old examples/animation.py @@ -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") diff --git a/sandbox/borders.py b/sandbox/borders.py index 04161dd62..e67456ffa 100644 --- a/sandbox/borders.py +++ b/sandbox/borders.py @@ -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; diff --git a/sandbox/buttons.py b/sandbox/buttons.py index 4ffd55dd8..883a457e2 100644 --- a/sandbox/buttons.py +++ b/sandbox/buttons.py @@ -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__": diff --git a/sandbox/color_names.py b/sandbox/color_names.py index 6e0d904e9..464b65c0c 100644 --- a/sandbox/color_names.py +++ b/sandbox/color_names.py @@ -23,7 +23,7 @@ class ColorDisplay(Widget, can_focus=True): class ColorNames(App): - CSS = """ + DEFAULT_CSS = """ ColorDisplay { height: 1; } diff --git a/sandbox/darren/buttons.py b/sandbox/darren/buttons.py index 4ffd55dd8..883a457e2 100644 --- a/sandbox/darren/buttons.py +++ b/sandbox/darren/buttons.py @@ -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__": diff --git a/sandbox/darren/file_search.py b/sandbox/darren/file_search.py index a1fbcefb1..240e2bec1 100644 --- a/sandbox/darren/file_search.py +++ b/sandbox/darren/file_search.py @@ -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() diff --git a/sandbox/fifty.py b/sandbox/fifty.py index 0a1f91027..c946e47ae 100644 --- a/sandbox/fifty.py +++ b/sandbox/fifty.py @@ -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() diff --git a/sandbox/input.py b/sandbox/input.py index 8df51a03d..5cfba8edc 100644 --- a/sandbox/input.py +++ b/sandbox/input.py @@ -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() diff --git a/sandbox/scroll_to_widget.py b/sandbox/scroll_to_widget.py index 81b0bf83c..209439c16 100644 --- a/sandbox/scroll_to_widget.py +++ b/sandbox/scroll_to_widget.py @@ -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; diff --git a/sandbox/vertical_container.py b/sandbox/vertical_container.py index bb1fca46f..a06e1cd94 100644 --- a/sandbox/vertical_container.py +++ b/sandbox/vertical_container.py @@ -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; diff --git a/sandbox/will/add_remove.py b/sandbox/will/add_remove.py index 5d95bd4da..a34ddd617 100644 --- a/sandbox/will/add_remove.py +++ b/sandbox/will/add_remove.py @@ -11,7 +11,7 @@ class Thing(Static): class AddRemoveApp(App): - CSS = """ + DEFAULT_CSS = """ #buttons { dock: top; height: auto; diff --git a/sandbox/will/calculator.css b/sandbox/will/calculator.css index 90b1c7617..0d777edfa 100644 --- a/sandbox/will/calculator.css +++ b/sandbox/will/calculator.css @@ -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; } diff --git a/sandbox/will/calculator.py b/sandbox/will/calculator.py index 44a6c3926..dabf99376 100644 --- a/sandbox/will/calculator.py +++ b/sandbox/will/calculator.py @@ -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__": diff --git a/sandbox/will/center.py b/sandbox/will/center.py index b5f5a08b2..dddaf086c 100644 --- a/sandbox/will/center.py +++ b/sandbox/will/center.py @@ -3,7 +3,7 @@ from textual.widgets import Static class CenterApp(App): - CSS = """ + DEFAULT_CSS = """ CenterApp Screen { layout: center; diff --git a/sandbox/will/center2.py b/sandbox/will/center2.py index 1711fd09a..d0191bf48 100644 --- a/sandbox/will/center2.py +++ b/sandbox/will/center2.py @@ -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() diff --git a/sandbox/will/just_a_box.py b/sandbox/will/just_a_box.py index 11c46589f..ea35fa128 100644 --- a/sandbox/will/just_a_box.py +++ b/sandbox/will/just_a_box.py @@ -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") diff --git a/sandbox/will/screens.py b/sandbox/will/screens.py index c3f655fee..3a9f34dc2 100644 --- a/sandbox/will/screens.py +++ b/sandbox/will/screens.py @@ -21,7 +21,7 @@ class NewScreen(Screen): class ScreenApp(App): - CSS = """ + DEFAULT_CSS = """ ScreenApp Screen { background: #111144; color: white; diff --git a/sandbox/will/scroll.py b/sandbox/will/scroll.py index 57017a6eb..d4b102201 100644 --- a/sandbox/will/scroll.py +++ b/sandbox/will/scroll.py @@ -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() diff --git a/sandbox/will/spacing.css b/sandbox/will/spacing.css index 911512567..5e1aec208 100644 --- a/sandbox/will/spacing.css +++ b/sandbox/will/spacing.css @@ -8,6 +8,6 @@ Static { background: blue 20%; height: 100%; margin: 2 4; - min-width: 30; - visibility: hidden; + min-width: 80; + min-height: 40; } diff --git a/sandbox/will/tree.py b/sandbox/will/tree.py index 158009232..75b1ef283 100644 --- a/sandbox/will/tree.py +++ b/sandbox/will/tree.py @@ -5,7 +5,7 @@ from textual.widgets import DirectoryTree class TreeApp(App): - CSS = """ + DEFAULT_CSS = """ Screen { overflow: auto; diff --git a/src/textual/__init__.py b/src/textual/__init__.py index 197a92f40..7be25a7f4 100644 --- a/src/textual/__init__.py +++ b/src/textual/__init__.py @@ -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 ._context import active_app +from ._log import LogGroup, LogVerbosity, LogSeverity - app = active_app.get() - caller = inspect.stack()[1] - app.log(*args, verbosity=verbosity, _textual_calling_frame=caller, **kwargs) +@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( + 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: diff --git a/src/textual/_log.py b/src/textual/_log.py new file mode 100644 index 000000000..ec9674cda --- /dev/null +++ b/src/textual/_log.py @@ -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 diff --git a/src/textual/app.py b/src/textual/app.py index 207d480cb..21c1f479f 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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,22 +508,24 @@ 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) - ) + self.devtools.log( + DevtoolsLog(objects, caller=_textual_calling_frame), + group, + verbosity, + severity, + ) else: output = " ".join(str(arg) for arg in objects) if kwargs: @@ -531,12 +533,12 @@ 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) - ) + self.devtools.log( + 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" 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. diff --git a/src/textual/cli/cli.py b/src/textual/cli/cli.py index f91df0e78..3e6cb2359 100644 --- a/src/textual/cli/cli.py +++ b/src/textual/cli/cli.py @@ -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) diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index 284bd20d8..7ea876355 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -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)) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 8dec85133..a476460a9 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -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 , . diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 388c44501..d696525a8 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -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; diff --git a/src/textual/devtools/borders.py b/src/textual/devtools/borders.py index 27813cdfa..376bd79d4 100644 --- a/src/textual/devtools/borders.py +++ b/src/textual/devtools/borders.py @@ -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; diff --git a/src/textual/devtools/client.py b/src/textual/devtools/client.py index 802bd686a..991f740fd 100644 --- a/src/textual/devtools/client.py +++ b/src/textual/devtools/client.py @@ -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), diff --git a/src/textual/devtools/redirect_output.py b/src/textual/devtools/redirect_output.py index 801e52c4b..3122da9ec 100644 --- a/src/textual/devtools/redirect_output.py +++ b/src/textual/devtools/redirect_output.py @@ -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, + ) diff --git a/src/textual/devtools/renderables.py b/src/textual/devtools/renderables.py index de0c30929..cd0035d47 100644 --- a/src/textual/devtools/renderables.py +++ b/src/textual/devtools/renderables.py @@ -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,14 +88,26 @@ 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 - yield Segments(self.segments) + if group == "PRINT": + yield Styled(Segments(self.segments), "bold") + else: + yield Segments(self.segments) class DevConsoleNotice: diff --git a/src/textual/devtools/server.py b/src/textual/devtools/server.py index a0ad3a149..167696563 100644 --- a/src/textual/devtools/server.py +++ b/src/textual/devtools/server.py @@ -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( diff --git a/src/textual/devtools/service.py b/src/textual/devtools/service.py index 5b001d137..7d8ef9a21 100644 --- a/src/textual/devtools/service.py +++ b/src/textual/devtools/service.py @@ -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 diff --git a/src/textual/dom.py b/src/textual/dom.py index a79058e78..ba53f0775 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -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)) diff --git a/src/textual/events.py b/src/textual/events.py index c8bad966e..e868eedab 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -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 diff --git a/src/textual/layout.py b/src/textual/layout.py index b1cc7a008..b06c347c3 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -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; } diff --git a/src/textual/message.py b/src/textual/message.py index 869c85ac8..63be90749 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -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: diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index e7eeb4c18..7830ba168 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -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) diff --git a/src/textual/messages.py b/src/textual/messages.py index 58e4cd2a6..55e42954d 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -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) diff --git a/src/textual/screen.py b/src/textual/screen.py index 303c76ea0..afeffdc46 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -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; diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index 8312be117..6397c9b04 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -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). diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index b9dd00774..156954323 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -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,19 +130,23 @@ class ScrollBarRender: ] * (end_index - start_index) if start_index < len(segments): - segments[start_index] = _Segment( - bars[8 - start_bar] * width_thickness, - _Style(bgcolor=back, color=bar, meta=foreground_meta) - if vertical - else _Style(bgcolor=bar, color=back, meta=foreground_meta), - ) + bar_character = bars[7 - start_bar] + if bar_character != " ": + segments[start_index] = _Segment( + 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): - segments[end_index] = _Segment( - bars[8 - end_bar] * width_thickness, - _Style(bgcolor=bar, color=back, meta=foreground_meta) - if vertical - else _Style(bgcolor=back, color=bar, meta=foreground_meta), - ) + bar_character = bars[7 - end_bar] + if bar_character != " ": + segments[end_index] = _Segment( + bar_character * width_thickness, + _Style(bgcolor=bar, color=back, meta=foreground_meta) + if vertical + else _Style(bgcolor=back, color=bar, meta=foreground_meta), + ) else: style = _Style(bgcolor=back) segments = [_Segment(blank, style=style)] * int(size) diff --git a/src/textual/widget.py b/src/textual/widget.py index 3f0e2f2bb..2555a8584 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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: diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 3121ef9c4..68c50839a 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -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; diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index c4059c000..71a667c08 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -106,7 +106,7 @@ class Coord(NamedTuple): class DataTable(ScrollView, Generic[CellType], can_focus=True): - CSS = """ + DEFAULT_CSS = """ DataTable { background: $surface; color: $text-surface; diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 66211a450..987193087 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -13,7 +13,7 @@ from ..widget import Widget @rich.repr.auto class Footer(Widget): - CSS = """ + DEFAULT_CSS = """ Footer { background: $accent; color: $text-accent; diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index e54c6ce6d..26cb4bea0 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -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%; diff --git a/src/textual/widgets/_pretty.py b/src/textual/widgets/_pretty.py index 3d4369e52..ff43350f5 100644 --- a/src/textual/widgets/_pretty.py +++ b/src/textual/widgets/_pretty.py @@ -7,7 +7,7 @@ from ..widget import Widget class Pretty(Widget): - CSS = """ + DEFAULT_CSS = """ Static { height: auto; } diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 1813d530f..824248ce7 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -25,7 +25,7 @@ def _check_renderable(renderable: object): class Static(Widget): - CSS = """ + DEFAULT_CSS = """ Static { height: auto; } diff --git a/src/textual/widgets/_tree_control.py b/src/textual/widgets/_tree_control.py index 86d2b0e9e..b3200c071 100644 --- a/src/textual/widgets/_tree_control.py +++ b/src/textual/widgets/_tree_control.py @@ -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; diff --git a/src/textual/widgets/text_input.py b/src/textual/widgets/text_input.py index 4e9c71f62..966faf2a1 100644 --- a/src/textual/widgets/text_input.py +++ b/src/textual/widgets/text_input.py @@ -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: diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index ac732f6f7..be54fe936 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -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) diff --git a/tests/devtools/conftest.py b/tests/devtools/conftest.py index 92f6253a3..b819512e2 100644 --- a/tests/devtools/conftest.py +++ b/tests/devtools/conftest.py @@ -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() diff --git a/tests/devtools/test_devtools.py b/tests/devtools/test_devtools.py index f66766445..b8c65bfd1 100644 --- a/tests/devtools/test_devtools.py +++ b/tests/devtools/test_devtools.py @@ -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 diff --git a/tests/devtools/test_devtools_client.py b/tests/devtools/test_devtools_client.py index 84a57b3c6..0f5f6e490 100644 --- a/tests/devtools/test_devtools_client.py +++ b/tests/devtools/test_devtools_client.py @@ -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": { - "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.", - }, - } + + +{ + "type": "client_log", + "payload": { + "group": 0, + "verbosity": 0, + "severity": 0, + "timestamp": 1649166819, + "path": "a/b/c.py", + "line_number": 123, + "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, diff --git a/tests/devtools/test_redirect_output.py b/tests/devtools/test_redirect_output.py index 349c2aa69..fd4659940 100644 --- a/tests/devtools/test_redirect_output.py +++ b/tests/devtools/test_redirect_output.py @@ -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="") diff --git a/tests/test_integration_layout.py b/tests/test_integration_layout.py index d60112393..46106dd5f 100644 --- a/tests/test_integration_layout.py +++ b/tests/test_integration_layout.py @@ -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; diff --git a/tests/test_integration_scrolling.py b/tests/test_integration_scrolling.py index b3059b146..8c4175c62 100644 --- a/tests/test_integration_scrolling.py +++ b/tests/test_integration_scrolling.py @@ -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 */ } diff --git a/tests/utilities/test_app.py b/tests/utilities/test_app.py index e23952b23..64eb637cb 100644 --- a/tests/utilities/test_app.py +++ b/tests/utilities/test_app.py @@ -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