mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'css' into invisible-widgets
This commit is contained in:
@@ -7,7 +7,7 @@ from textual.widget import Widget
|
|||||||
class Clock(Widget):
|
class Clock(Widget):
|
||||||
"""A clock app."""
|
"""A clock app."""
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Clock {
|
Clock {
|
||||||
content-align: center middle;
|
content-align: center middle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from textual.widgets import Button
|
|||||||
|
|
||||||
class ButtonApp(App):
|
class ButtonApp(App):
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Button {
|
Button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,4 +35,4 @@ class SmoothApp(App):
|
|||||||
# self.set_timer(10, lambda: self.action("quit"))
|
# self.set_timer(10, lambda: self.action("quit"))
|
||||||
|
|
||||||
|
|
||||||
SmoothApp.run(log_path="textual.log", log_verbosity=2)
|
SmoothApp.run(log_path="textual.log")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from textual.widgets import Placeholder
|
|||||||
|
|
||||||
|
|
||||||
class VerticalContainer(Widget):
|
class VerticalContainer(Widget):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
VerticalContainer {
|
VerticalContainer {
|
||||||
layout: vertical;
|
layout: vertical;
|
||||||
overflow: hidden auto;
|
overflow: hidden auto;
|
||||||
@@ -24,7 +24,7 @@ class VerticalContainer(Widget):
|
|||||||
|
|
||||||
|
|
||||||
class Introduction(Widget):
|
class Introduction(Widget):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Introduction {
|
Introduction {
|
||||||
background: indigo;
|
background: indigo;
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ class ButtonsApp(App[str]):
|
|||||||
|
|
||||||
|
|
||||||
app = ButtonsApp(
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ColorDisplay(Widget, can_focus=True):
|
|||||||
|
|
||||||
|
|
||||||
class ColorNames(App):
|
class ColorNames(App):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
ColorDisplay {
|
ColorDisplay {
|
||||||
height: 1;
|
height: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ class ButtonsApp(App[str]):
|
|||||||
|
|
||||||
|
|
||||||
app = ButtonsApp(
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -62,9 +62,7 @@ class FileSearchApp(App):
|
|||||||
self.file_table.filter = event.value
|
self.file_table.filter = event.value
|
||||||
|
|
||||||
|
|
||||||
app = FileSearchApp(
|
app = FileSearchApp(log_path="textual.log", css_path="file_search.scss", watch_css=True)
|
||||||
log_path="textual.log", css_path="file_search.scss", watch_css=True, log_verbosity=2
|
|
||||||
)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
result = app.run()
|
result = app.run()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from textual.widget import Widget
|
|||||||
|
|
||||||
class FiftyApp(App):
|
class FiftyApp(App):
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Screen {
|
Screen {
|
||||||
layout: vertical;
|
layout: vertical;
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,7 @@ class FiftyApp(App):
|
|||||||
yield layout.Horizontal(Widget(), Widget())
|
yield layout.Horizontal(Widget(), Widget())
|
||||||
yield layout.Horizontal(Widget(), Widget())
|
yield layout.Horizontal(Widget(), Widget())
|
||||||
|
|
||||||
|
|
||||||
app = FiftyApp()
|
app = FiftyApp()
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run()
|
app.run()
|
||||||
|
|||||||
@@ -61,9 +61,7 @@ class InputApp(App[str]):
|
|||||||
self.celsius.value = f"{celsius:.1f}"
|
self.celsius.value = f"{celsius:.1f}"
|
||||||
|
|
||||||
|
|
||||||
app = InputApp(
|
app = InputApp(log_path="textual.log", css_path="input.scss", watch_css=True)
|
||||||
log_path="textual.log", css_path="input.scss", watch_css=True, log_verbosity=2
|
|
||||||
)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
result = app.run()
|
result = app.run()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ placeholders_count = 12
|
|||||||
|
|
||||||
|
|
||||||
class VerticalContainer(Widget):
|
class VerticalContainer(Widget):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
VerticalContainer {
|
VerticalContainer {
|
||||||
layout: vertical;
|
layout: vertical;
|
||||||
overflow: hidden auto;
|
overflow: hidden auto;
|
||||||
@@ -26,7 +26,7 @@ class VerticalContainer(Widget):
|
|||||||
|
|
||||||
|
|
||||||
class Introduction(Widget):
|
class Introduction(Widget):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Introduction {
|
Introduction {
|
||||||
background: indigo;
|
background: indigo;
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ initial_placeholders_count = 4
|
|||||||
|
|
||||||
|
|
||||||
class VerticalContainer(Widget):
|
class VerticalContainer(Widget):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
VerticalContainer {
|
VerticalContainer {
|
||||||
layout: vertical;
|
layout: vertical;
|
||||||
overflow: hidden auto;
|
overflow: hidden auto;
|
||||||
@@ -30,7 +30,7 @@ class VerticalContainer(Widget):
|
|||||||
|
|
||||||
|
|
||||||
class Introduction(Widget):
|
class Introduction(Widget):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Introduction {
|
Introduction {
|
||||||
background: indigo;
|
background: indigo;
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class Thing(Static):
|
|||||||
|
|
||||||
|
|
||||||
class AddRemoveApp(App):
|
class AddRemoveApp(App):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
#buttons {
|
#buttons {
|
||||||
dock: top;
|
dock: top;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ Screen {
|
|||||||
table-columns: 1fr;
|
table-columns: 1fr;
|
||||||
table-rows: 2fr 1fr 1fr 1fr 1fr 1fr;
|
table-rows: 2fr 1fr 1fr 1fr 1fr 1fr;
|
||||||
margin: 1 2;
|
margin: 1 2;
|
||||||
min-height:26;
|
min-height:25;
|
||||||
min-width: 50;
|
min-width: 26;
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
@@ -18,18 +18,15 @@ Button {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.display {
|
#numbers {
|
||||||
column-span: 4;
|
column-span: 4;
|
||||||
content-align: right middle;
|
content-align: right middle;
|
||||||
padding: 0 1;
|
padding: 0 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: $panel-darken-2;
|
background: $primary-lighten-2;
|
||||||
|
color: $text-primary-lighten-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.special {
|
#number-0 {
|
||||||
tint: $text-panel 20%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zero {
|
|
||||||
column-span: 2;
|
column-span: 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.layout import Container
|
||||||
|
from textual.reactive import Reactive
|
||||||
from textual.widgets import Button, Static
|
from textual.widgets import Button, Static
|
||||||
|
|
||||||
|
|
||||||
class CalculatorApp(App):
|
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(
|
yield Container(
|
||||||
Static("0", classes="display"),
|
Static(id="numbers"),
|
||||||
Button("AC", classes="special"),
|
Button("AC", id="ac", variant="primary"),
|
||||||
Button("+/-", classes="special"),
|
Button("C", id="c", variant="primary"),
|
||||||
Button("%", classes="special"),
|
Button("+/-", id="plus-minus", variant="primary"),
|
||||||
Button("÷", variant="warning"),
|
Button("%", id="percent", variant="primary"),
|
||||||
Button("7"),
|
Button("÷", id="divide", variant="warning"),
|
||||||
Button("8"),
|
Button("7", id="number-7"),
|
||||||
Button("9"),
|
Button("8", id="number-8"),
|
||||||
Button("×", variant="warning"),
|
Button("9", id="number-9"),
|
||||||
Button("4"),
|
Button("×", id="multiply", variant="warning"),
|
||||||
Button("5"),
|
Button("4", id="number-4"),
|
||||||
Button("6"),
|
Button("5", id="number-5"),
|
||||||
Button("-", variant="warning"),
|
Button("6", id="number-6"),
|
||||||
Button("1"),
|
Button("-", id="minus", variant="warning"),
|
||||||
Button("2"),
|
Button("1", id="number-1"),
|
||||||
Button("3"),
|
Button("2", id="number-2"),
|
||||||
Button("+", variant="warning"),
|
Button("3", id="number-3"),
|
||||||
Button("0", classes="operator zero"),
|
Button("+", id="plus", variant="warning"),
|
||||||
Button("."),
|
Button("0", id="number-0"),
|
||||||
Button("=", variant="warning"),
|
Button(".", id="point"),
|
||||||
|
Button("=", id="equals", variant="warning"),
|
||||||
id="calculator",
|
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")
|
app = CalculatorApp(css_path="calculator.css")
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from textual.widgets import Static
|
|||||||
|
|
||||||
|
|
||||||
class CenterApp(App):
|
class CenterApp(App):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
|
|
||||||
CenterApp Screen {
|
CenterApp Screen {
|
||||||
layout: center;
|
layout: center;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from textual.widgets import Static
|
|||||||
|
|
||||||
|
|
||||||
class CenterApp(App):
|
class CenterApp(App):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
|
|
||||||
#sidebar {
|
#sidebar {
|
||||||
dock: left;
|
dock: left;
|
||||||
@@ -52,4 +52,4 @@ class CenterApp(App):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
app = CenterApp(log_verbosity=3)
|
app = CenterApp()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from textual.widget import Widget
|
|||||||
|
|
||||||
|
|
||||||
class Box(Widget, can_focus=True):
|
class Box(Widget, can_focus=True):
|
||||||
CSS = "#box {background: blue;}"
|
DEFAULT_CSS = "#box {background: blue;}"
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
return Panel("Box")
|
return Panel("Box")
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class NewScreen(Screen):
|
|||||||
|
|
||||||
|
|
||||||
class ScreenApp(App):
|
class ScreenApp(App):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
ScreenApp Screen {
|
ScreenApp Screen {
|
||||||
background: #111144;
|
background: #111144;
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -37,9 +37,7 @@ class ButtonsApp(App[str]):
|
|||||||
self.dark = not self.dark
|
self.dark = not self.dark
|
||||||
|
|
||||||
|
|
||||||
app = ButtonsApp(
|
app = ButtonsApp(log_path="textual.log", css_path="buttons.css", watch_css=True)
|
||||||
log_path="textual.log", css_path="buttons.css", watch_css=True, log_verbosity=3
|
|
||||||
)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
result = app.run()
|
result = app.run()
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ Static {
|
|||||||
background: blue 20%;
|
background: blue 20%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 2 4;
|
margin: 2 4;
|
||||||
min-width: 30;
|
min-width: 80;
|
||||||
visibility: hidden;
|
min-height: 40;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from textual.widgets import DirectoryTree
|
|||||||
|
|
||||||
|
|
||||||
class TreeApp(App):
|
class TreeApp(App):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Screen {
|
Screen {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
from rich.console import RenderableType
|
||||||
|
|
||||||
__all__ = ["log", "panic"]
|
__all__ = ["log", "panic"]
|
||||||
|
|
||||||
|
|
||||||
def log(*args: object, verbosity: int = 0, **kwargs) -> None:
|
from ._log import LogGroup, LogVerbosity, LogSeverity
|
||||||
# TODO: There may be an early-out here for when there is no endpoint for logs
|
|
||||||
from ._context import active_app
|
|
||||||
|
|
||||||
app = active_app.get()
|
|
||||||
|
|
||||||
caller = inspect.stack()[1]
|
@rich.repr.auto
|
||||||
app.log(*args, verbosity=verbosity, _textual_calling_frame=caller, **kwargs)
|
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:
|
def panic(*args: RenderableType) -> None:
|
||||||
|
|||||||
27
src/textual/_log.py
Normal file
27
src/textual/_log.py
Normal 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
|
||||||
@@ -7,7 +7,7 @@ import os
|
|||||||
import platform
|
import platform
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
from contextlib import redirect_stdout, redirect_stderr
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import PurePath, Path
|
from pathlib import PurePath, Path
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
@@ -40,7 +40,16 @@ from rich.protocol import is_renderable
|
|||||||
from rich.segment import Segments
|
from rich.segment import Segments
|
||||||
from rich.traceback import Traceback
|
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 ._animator import Animator
|
||||||
from ._callback import invoke
|
from ._callback import invoke
|
||||||
from ._context import active_app
|
from ._context import active_app
|
||||||
@@ -134,7 +143,6 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
driver_class (Type[Driver] | None, optional): Driver class or ``None`` to auto-detect. Defaults to None.
|
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.
|
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``.
|
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.
|
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 {
|
App {
|
||||||
background: $background;
|
background: $background;
|
||||||
color: $text-background;
|
color: $text-background;
|
||||||
@@ -156,11 +168,6 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
driver_class: Type[Driver] | None = None,
|
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,
|
title: str | None = None,
|
||||||
css_path: str | PurePath | None = None,
|
css_path: str | PurePath | None = None,
|
||||||
watch_css: bool = False,
|
watch_css: bool = False,
|
||||||
@@ -201,20 +208,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
else:
|
else:
|
||||||
self._title = title
|
self._title = title
|
||||||
|
|
||||||
self._log_console: Console | None = None
|
self._logger = Logger()
|
||||||
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.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False)
|
self.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False)
|
||||||
self._refresh_required = False
|
self._refresh_required = False
|
||||||
@@ -486,10 +480,16 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
"""
|
"""
|
||||||
return Size(*self.console.size)
|
return Size(*self.console.size)
|
||||||
|
|
||||||
def log(
|
@property
|
||||||
|
def log(self) -> Logger:
|
||||||
|
return self._logger
|
||||||
|
|
||||||
|
def _log(
|
||||||
self,
|
self,
|
||||||
|
group: LogGroup,
|
||||||
|
verbosity: LogVerbosity,
|
||||||
|
severity: LogSeverity,
|
||||||
*objects: Any,
|
*objects: Any,
|
||||||
verbosity: int = 1,
|
|
||||||
_textual_calling_frame: inspect.FrameInfo | None = None,
|
_textual_calling_frame: inspect.FrameInfo | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -508,22 +508,24 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
Args:
|
Args:
|
||||||
verbosity (int, optional): Verbosity level 0-3. Defaults to 1.
|
verbosity (int, optional): Verbosity level 0-3. Defaults to 1.
|
||||||
"""
|
"""
|
||||||
if verbosity > self.log_verbosity:
|
|
||||||
return
|
if not self.devtools.is_connected:
|
||||||
if self._log_console is None and not self.devtools.is_connected:
|
|
||||||
return
|
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]
|
_textual_calling_frame = inspect.stack()[1]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if len(objects) == 1 and not kwargs:
|
if len(objects) == 1 and not kwargs:
|
||||||
if self._log_console is not None:
|
self.devtools.log(
|
||||||
self._log_console.print(objects[0])
|
DevtoolsLog(objects, caller=_textual_calling_frame),
|
||||||
if self.devtools.is_connected:
|
group,
|
||||||
self.devtools.log(
|
verbosity,
|
||||||
DevtoolsLog(objects, caller=_textual_calling_frame)
|
severity,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
output = " ".join(str(arg) for arg in objects)
|
output = " ".join(str(arg) for arg in objects)
|
||||||
if kwargs:
|
if kwargs:
|
||||||
@@ -531,12 +533,12 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
f"{key}={value!r}" for key, value in kwargs.items()
|
f"{key}={value!r}" for key, value in kwargs.items()
|
||||||
)
|
)
|
||||||
output = f"{output} {key_values}" if output else key_values
|
output = f"{output} {key_values}" if output else key_values
|
||||||
if self._log_console is not None:
|
self.devtools.log(
|
||||||
self._log_console.print(output, soft_wrap=True)
|
DevtoolsLog(output, caller=_textual_calling_frame),
|
||||||
if self.devtools.is_connected:
|
group,
|
||||||
self.devtools.log(
|
verbosity,
|
||||||
DevtoolsLog(output, caller=_textual_calling_frame)
|
severity,
|
||||||
)
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
self.on_exception(error)
|
self.on_exception(error)
|
||||||
|
|
||||||
@@ -683,7 +685,6 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
async def _on_css_change(self) -> None:
|
async def _on_css_change(self) -> None:
|
||||||
"""Called when the CSS changes (if watch_css is True)."""
|
"""Called when the CSS changes (if watch_css is True)."""
|
||||||
if self.css_path is not None:
|
if self.css_path is not None:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
time = perf_counter()
|
time = perf_counter()
|
||||||
stylesheet = self.stylesheet.copy()
|
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")
|
self.log(f"<stylesheet> loaded {self.css_path!r} in {elapsed:.0f} ms")
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
# TODO: Catch specific exceptions
|
# TODO: Catch specific exceptions
|
||||||
|
self.log.error(error)
|
||||||
self.bell()
|
self.bell()
|
||||||
self.log(error)
|
|
||||||
else:
|
else:
|
||||||
self.stylesheet = stylesheet
|
self.stylesheet = stylesheet
|
||||||
self.reset_styles()
|
self.reset_styles()
|
||||||
@@ -1042,14 +1043,13 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
if self.devtools_enabled:
|
if self.devtools_enabled:
|
||||||
try:
|
try:
|
||||||
await self.devtools.connect()
|
await self.devtools.connect()
|
||||||
self.log(f"Connected to devtools ({self.devtools.url})")
|
self.log(f"Connected to devtools ( {self.devtools.url} )")
|
||||||
except DevtoolsConnectionError:
|
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("---")
|
||||||
|
|
||||||
self.log(driver=self.driver_class)
|
self.log(driver=self.driver_class)
|
||||||
self.log(log_verbosity=self.log_verbosity)
|
|
||||||
self.log(loop=asyncio.get_running_loop())
|
self.log(loop=asyncio.get_running_loop())
|
||||||
self.log(features=self.features)
|
self.log(features=self.features)
|
||||||
|
|
||||||
@@ -1060,13 +1060,23 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
self.stylesheet.add_source(
|
self.stylesheet.add_source(
|
||||||
css, path=path, is_default_css=True, tie_breaker=tie_breaker
|
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:
|
except Exception as error:
|
||||||
self.on_exception(error)
|
self.on_exception(error)
|
||||||
self._print_error_renderables()
|
self._print_error_renderables()
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.css_monitor:
|
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)
|
self.log("[b green]STARTED[/]", self.css_monitor)
|
||||||
|
|
||||||
process_messages = super()._process_messages
|
process_messages = super()._process_messages
|
||||||
@@ -1103,7 +1113,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
if self.is_headless:
|
if self.is_headless:
|
||||||
await run_process_messages()
|
await run_process_messages()
|
||||||
else:
|
else:
|
||||||
redirector = StdoutRedirector(self.devtools, self._log_file)
|
redirector = StdoutRedirector(self.devtools)
|
||||||
with redirect_stderr(redirector):
|
with redirect_stderr(redirector):
|
||||||
with redirect_stdout(redirector): # type: ignore
|
with redirect_stdout(redirector): # type: ignore
|
||||||
await run_process_messages()
|
await run_process_messages()
|
||||||
@@ -1116,13 +1126,6 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
self._print_error_renderables()
|
self._print_error_renderables()
|
||||||
if self.devtools.is_connected:
|
if self.devtools.is_connected:
|
||||||
await self._disconnect_devtools()
|
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:
|
async def _ready(self) -> None:
|
||||||
"""Called immediately prior to processing messages.
|
"""Called immediately prior to processing messages.
|
||||||
|
|||||||
@@ -21,14 +21,16 @@ def run():
|
|||||||
|
|
||||||
|
|
||||||
@run.command(help="Run the Textual Devtools console.")
|
@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
|
from rich.console import Console
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
console.clear()
|
console.clear()
|
||||||
console.show_cursor(False)
|
console.show_cursor(False)
|
||||||
try:
|
try:
|
||||||
_run_devtools()
|
_run_devtools(verbose=verbose, exclude=exclude)
|
||||||
finally:
|
finally:
|
||||||
console.show_cursor(True)
|
console.show_cursor(True)
|
||||||
|
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ def parse(
|
|||||||
path (str): Path to the CSS
|
path (str): Path to the CSS
|
||||||
variables (dict[str, str]): Substitution variables to substitute tokens for.
|
variables (dict[str, str]): Substitution variables to substitute tokens for.
|
||||||
is_default_rules (bool): True if the rules we're extracting are
|
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 {})
|
variable_tokens = tokenize_values(variables or {})
|
||||||
tokens = iter(substitute_references(tokenize(css, path), variable_tokens))
|
tokens = iter(substitute_references(tokenize(css, path), variable_tokens))
|
||||||
|
|||||||
@@ -571,7 +571,7 @@ class Styles(StylesBase):
|
|||||||
Args:
|
Args:
|
||||||
specificity (Specificity3): A node specificity.
|
specificity (Specificity3): A node specificity.
|
||||||
is_default_rules (bool): True if the rules we're extracting are
|
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:
|
Returns:
|
||||||
list[tuple[str, Specificity5, Any]]]: A list containing a tuple of <RULE NAME>, <SPECIFICITY> <RULE VALUE>.
|
list[tuple[str, Specificity5, Any]]]: A list containing a tuple of <RULE NAME>, <SPECIFICITY> <RULE VALUE>.
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ class Stylesheet:
|
|||||||
css (str): String containing Textual CSS.
|
css (str): String containing Textual CSS.
|
||||||
path (str | PurePath): Path to CSS or unique identifier
|
path (str | PurePath): Path to CSS or unique identifier
|
||||||
is_default_rules (bool): True if the rules we're extracting are
|
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:
|
Raises:
|
||||||
StylesheetError: If the CSS is invalid.
|
StylesheetError: If the CSS is invalid.
|
||||||
@@ -555,7 +555,7 @@ if __name__ == "__main__":
|
|||||||
print(app.tree)
|
print(app.tree)
|
||||||
print()
|
print()
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
App > View {
|
App > View {
|
||||||
layout: dock;
|
layout: dock;
|
||||||
docks: sidebar=left | widgets=top;
|
docks: sidebar=left | widgets=top;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Where the fear has gone there will be nothing. Only I will remain."""
|
|||||||
|
|
||||||
|
|
||||||
class BorderButtons(layout.Vertical):
|
class BorderButtons(layout.Vertical):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
BorderButtons {
|
BorderButtons {
|
||||||
dock: left;
|
dock: left;
|
||||||
width: 24;
|
width: 24;
|
||||||
@@ -34,7 +34,7 @@ class BorderButtons(layout.Vertical):
|
|||||||
class BorderApp(App):
|
class BorderApp(App):
|
||||||
"""Demonstrates the border styles."""
|
"""Demonstrates the border styles."""
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Static {
|
Static {
|
||||||
margin: 2 4;
|
margin: 2 4;
|
||||||
padding: 2 4;
|
padding: 2 4;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from typing import Type, Any, NamedTuple
|
|||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.segment import Segment
|
from rich.segment import Segment
|
||||||
|
|
||||||
|
from .._log import LogGroup, LogVerbosity, LogSeverity
|
||||||
|
|
||||||
|
|
||||||
class DevtoolsDependenciesMissingError(Exception):
|
class DevtoolsDependenciesMissingError(Exception):
|
||||||
"""Raise when the required devtools dependencies are not installed in the environment"""
|
"""Raise when the required devtools dependencies are not installed in the environment"""
|
||||||
@@ -115,6 +117,7 @@ class DevtoolsClient:
|
|||||||
self.websocket: ClientWebSocketResponse | None = None
|
self.websocket: ClientWebSocketResponse | None = None
|
||||||
self.log_queue: Queue[str | bytes | Type[ClientShutdown]] | None = None
|
self.log_queue: Queue[str | bytes | Type[ClientShutdown]] | None = None
|
||||||
self.spillover: int = 0
|
self.spillover: int = 0
|
||||||
|
self.verbose: bool = False
|
||||||
|
|
||||||
async def connect(self) -> None:
|
async def connect(self) -> None:
|
||||||
"""Connect to the devtools server.
|
"""Connect to the devtools server.
|
||||||
@@ -136,7 +139,7 @@ class DevtoolsClient:
|
|||||||
log_queue = self.log_queue
|
log_queue = self.log_queue
|
||||||
websocket = self.websocket
|
websocket = self.websocket
|
||||||
|
|
||||||
async def update_console():
|
async def update_console() -> None:
|
||||||
"""Coroutine function scheduled as a Task, which listens on
|
"""Coroutine function scheduled as a Task, which listens on
|
||||||
the websocket for updates from the server regarding any changes
|
the websocket for updates from the server regarding any changes
|
||||||
in the server Console dimensions. When the client learns of this
|
in the server Console dimensions. When the client learns of this
|
||||||
@@ -150,6 +153,7 @@ class DevtoolsClient:
|
|||||||
payload = message_json["payload"]
|
payload = message_json["payload"]
|
||||||
self.console.width = payload["width"]
|
self.console.width = payload["width"]
|
||||||
self.console.height = payload["height"]
|
self.console.height = payload["height"]
|
||||||
|
self.verbose = payload.get("verbose", False)
|
||||||
|
|
||||||
async def send_queued_logs():
|
async def send_queued_logs():
|
||||||
"""Coroutine function which is scheduled as a Task, which consumes
|
"""Coroutine function which is scheduled as a Task, which consumes
|
||||||
@@ -209,7 +213,13 @@ class DevtoolsClient:
|
|||||||
return False
|
return False
|
||||||
return not (self.session.closed or self.websocket.closed)
|
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.
|
"""Queue a log to be sent to the devtools server for display.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -227,6 +237,9 @@ class DevtoolsClient:
|
|||||||
{
|
{
|
||||||
"type": "client_log",
|
"type": "client_log",
|
||||||
"payload": {
|
"payload": {
|
||||||
|
"group": group.value,
|
||||||
|
"verbosity": verbosity.value,
|
||||||
|
"severity": severity.value,
|
||||||
"timestamp": int(time()),
|
"timestamp": int(time()),
|
||||||
"path": getattr(log.caller, "filename", ""),
|
"path": getattr(log.caller, "filename", ""),
|
||||||
"line_number": getattr(log.caller, "lineno", 0),
|
"line_number": getattr(log.caller, "lineno", 0),
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
from io import TextIOWrapper
|
|
||||||
from typing import TYPE_CHECKING, cast
|
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:
|
if TYPE_CHECKING:
|
||||||
from textual.devtools.client import DevtoolsClient
|
from .devtools.client import DevtoolsClient
|
||||||
|
|
||||||
|
|
||||||
class StdoutRedirector:
|
class StdoutRedirector:
|
||||||
@@ -17,16 +18,13 @@ class StdoutRedirector:
|
|||||||
log file.
|
log file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, devtools: DevtoolsClient) -> None:
|
||||||
self, devtools: DevtoolsClient, log_file: TextIOWrapper | None
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
devtools (DevtoolsClient): The running Textual app instance.
|
devtools (DevtoolsClient): The running Textual app instance.
|
||||||
log_file (TextIOWrapper): The log file for the Textual App.
|
log_file (TextIOWrapper): The log file for the Textual App.
|
||||||
"""
|
"""
|
||||||
self.devtools = devtools
|
self.devtools = devtools
|
||||||
self.log_file = log_file
|
|
||||||
self._buffer: list[DevtoolsLog] = []
|
self._buffer: list[DevtoolsLog] = []
|
||||||
|
|
||||||
def write(self, string: str) -> None:
|
def write(self, string: str) -> None:
|
||||||
@@ -38,7 +36,7 @@ class StdoutRedirector:
|
|||||||
string (str): The string to write to the buffer.
|
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
|
return
|
||||||
|
|
||||||
caller = inspect.stack()[1]
|
caller = inspect.stack()[1]
|
||||||
@@ -59,7 +57,6 @@ class StdoutRedirector:
|
|||||||
the devtools server and the log file. In the case of the devtools,
|
the devtools server and the log file. In the case of the devtools,
|
||||||
where possible, log messages will be batched and sent as one.
|
where possible, log messages will be batched and sent as one.
|
||||||
"""
|
"""
|
||||||
self._write_to_log_file()
|
|
||||||
self._write_to_devtools()
|
self._write_to_devtools()
|
||||||
self._buffer.clear()
|
self._buffer.clear()
|
||||||
|
|
||||||
@@ -81,20 +78,6 @@ class StdoutRedirector:
|
|||||||
if log_batch:
|
if log_batch:
|
||||||
self._log_devtools_batched(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:
|
def _log_devtools_batched(self, log_batch: list[DevtoolsLog]) -> None:
|
||||||
"""Write a single batch of logs to devtools. A batch means contiguous logs
|
"""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.
|
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.
|
# 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 = "".join(cast(str, log.objects_or_string) for log in log_batch)
|
||||||
batched_log = batched_log.rstrip()
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -18,15 +18,18 @@ from rich.markup import escape
|
|||||||
from rich.rule import Rule
|
from rich.rule import Rule
|
||||||
from rich.segment import Segment, Segments
|
from rich.segment import Segment, Segments
|
||||||
from rich.style import Style
|
from rich.style import Style
|
||||||
|
from rich.styled import Styled
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
from textual._log import LogGroup
|
||||||
from textual._border import Border
|
|
||||||
|
|
||||||
DevConsoleMessageLevel = Literal["info", "warning", "error"]
|
DevConsoleMessageLevel = Literal["info", "warning", "error"]
|
||||||
|
|
||||||
|
|
||||||
class DevConsoleHeader:
|
class DevConsoleHeader:
|
||||||
|
def __init__(self, verbose: bool = False) -> None:
|
||||||
|
self.verbose = verbose
|
||||||
|
|
||||||
def __rich_console__(
|
def __rich_console__(
|
||||||
self, console: Console, options: ConsoleOptions
|
self, console: Console, options: ConsoleOptions
|
||||||
) -> RenderResult:
|
) -> RenderResult:
|
||||||
@@ -35,6 +38,8 @@ class DevConsoleHeader:
|
|||||||
"[magenta]Run a Textual app with [reverse]textual run --dev my_app.py[/] to connect.\n"
|
"[magenta]Run a Textual app with [reverse]textual run --dev my_app.py[/] to connect.\n"
|
||||||
"[magenta]Press [reverse]Ctrl+C[/] to quit."
|
"[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)
|
render_options = options.update(width=options.max_width - 4)
|
||||||
lines = console.render_lines(preamble, render_options)
|
lines = console.render_lines(preamble, render_options)
|
||||||
|
|
||||||
@@ -63,11 +68,17 @@ class DevConsoleLog:
|
|||||||
path: str,
|
path: str,
|
||||||
line_number: int,
|
line_number: int,
|
||||||
unix_timestamp: int,
|
unix_timestamp: int,
|
||||||
|
group: int,
|
||||||
|
verbosity: int,
|
||||||
|
severity: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.segments = segments
|
self.segments = segments
|
||||||
self.path = path
|
self.path = path
|
||||||
self.line_number = line_number
|
self.line_number = line_number
|
||||||
self.unix_timestamp = unix_timestamp
|
self.unix_timestamp = unix_timestamp
|
||||||
|
self.group = group
|
||||||
|
self.verbosity = verbosity
|
||||||
|
self.severity = severity
|
||||||
|
|
||||||
def __rich_console__(
|
def __rich_console__(
|
||||||
self, console: Console, options: ConsoleOptions
|
self, console: Console, options: ConsoleOptions
|
||||||
@@ -77,14 +88,26 @@ class DevConsoleLog:
|
|||||||
|
|
||||||
file_link = escape(f"file://{Path(self.path).absolute()}")
|
file_link = escape(f"file://{Path(self.path).absolute()}")
|
||||||
file_and_line = escape(f"{Path(self.path).name}:{self.line_number}")
|
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(
|
table.add_row(
|
||||||
f"[dim]{local_time.time()}",
|
message,
|
||||||
Align.right(
|
Align.right(
|
||||||
Text(f"{file_and_line}", style=Style(dim=True, link=file_link))
|
Text(f"{file_and_line}", style=Style(dim=True, link=file_link))
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
yield table
|
yield table
|
||||||
yield Segments(self.segments)
|
if group == "PRINT":
|
||||||
|
yield Styled(Segments(self.segments), "bold")
|
||||||
|
else:
|
||||||
|
yield Segments(self.segments)
|
||||||
|
|
||||||
|
|
||||||
class DevConsoleNotice:
|
class DevConsoleNotice:
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ async def _on_startup(app: Application) -> None:
|
|||||||
await service.start()
|
await service.start()
|
||||||
|
|
||||||
|
|
||||||
def _run_devtools() -> None:
|
def _run_devtools(verbose: bool, exclude: list[str] | None = None) -> None:
|
||||||
app = _make_devtools_aiohttp_app()
|
app = _make_devtools_aiohttp_app(verbose=verbose, exclude=exclude)
|
||||||
|
|
||||||
def noop_print(_: str):
|
def noop_print(_: str):
|
||||||
return None
|
return None
|
||||||
@@ -47,14 +47,17 @@ def _run_devtools() -> None:
|
|||||||
|
|
||||||
def _make_devtools_aiohttp_app(
|
def _make_devtools_aiohttp_app(
|
||||||
size_change_poll_delay_secs: float = DEFAULT_SIZE_CHANGE_POLL_DELAY_SECONDS,
|
size_change_poll_delay_secs: float = DEFAULT_SIZE_CHANGE_POLL_DELAY_SECONDS,
|
||||||
|
verbose: bool = False,
|
||||||
|
exclude: list[str] | None = None,
|
||||||
) -> Application:
|
) -> Application:
|
||||||
app = Application()
|
app = Application()
|
||||||
|
|
||||||
app.on_shutdown.append(_on_shutdown)
|
app.on_shutdown.append(_on_shutdown)
|
||||||
app.on_startup.append(_on_startup)
|
app.on_startup.append(_on_startup)
|
||||||
|
|
||||||
|
app["verbose"] = verbose
|
||||||
app["service"] = DevtoolsService(
|
app["service"] = DevtoolsService(
|
||||||
update_frequency=size_change_poll_delay_secs,
|
update_frequency=size_change_poll_delay_secs, verbose=verbose, exclude=exclude
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_routes(
|
app.add_routes(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from rich.console import Console
|
|||||||
from rich.markup import escape
|
from rich.markup import escape
|
||||||
import msgpack
|
import msgpack
|
||||||
|
|
||||||
|
from textual._log import LogGroup
|
||||||
from textual.devtools.renderables import (
|
from textual.devtools.renderables import (
|
||||||
DevConsoleLog,
|
DevConsoleLog,
|
||||||
DevConsoleNotice,
|
DevConsoleNotice,
|
||||||
@@ -30,13 +31,22 @@ class DevtoolsService:
|
|||||||
responsible for tracking connected client applications.
|
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:
|
Args:
|
||||||
update_frequency (float): The number of seconds to wait between
|
update_frequency (float): The number of seconds to wait between
|
||||||
sending updates of the console size to connected clients.
|
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.update_frequency = update_frequency
|
||||||
|
self.verbose = verbose
|
||||||
|
self.exclude = set(name.upper() for name in exclude) if exclude else set()
|
||||||
self.console = Console()
|
self.console = Console()
|
||||||
self.shutdown_event = asyncio.Event()
|
self.shutdown_event = asyncio.Event()
|
||||||
self.clients: list[ClientHandler] = []
|
self.clients: list[ClientHandler] = []
|
||||||
@@ -44,7 +54,7 @@ class DevtoolsService:
|
|||||||
async def start(self):
|
async def start(self):
|
||||||
"""Starts devtools tasks"""
|
"""Starts devtools tasks"""
|
||||||
self.size_poll_task = asyncio.create_task(self._console_size_poller())
|
self.size_poll_task = asyncio.create_task(self._console_size_poller())
|
||||||
self.console.print(DevConsoleHeader())
|
self.console.print(DevConsoleHeader(verbose=self.verbose))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def clients_connected(self) -> bool:
|
def clients_connected(self) -> bool:
|
||||||
@@ -58,6 +68,7 @@ class DevtoolsService:
|
|||||||
"""
|
"""
|
||||||
current_width = self.console.width
|
current_width = self.console.width
|
||||||
current_height = self.console.height
|
current_height = self.console.height
|
||||||
|
await self._send_server_info_to_all()
|
||||||
while not self.shutdown_event.is_set():
|
while not self.shutdown_event.is_set():
|
||||||
width = self.console.width
|
width = self.console.width
|
||||||
height = self.console.height
|
height = self.console.height
|
||||||
@@ -91,6 +102,7 @@ class DevtoolsService:
|
|||||||
"payload": {
|
"payload": {
|
||||||
"width": self.console.width,
|
"width": self.console.width,
|
||||||
"height": self.console.height,
|
"height": self.console.height,
|
||||||
|
"verbose": self.verbose,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -168,10 +180,10 @@ class ClientHandler:
|
|||||||
|
|
||||||
type = message["type"]
|
type = message["type"]
|
||||||
if type == "client_log":
|
if type == "client_log":
|
||||||
path = message["payload"]["path"]
|
payload = message["payload"]
|
||||||
line_number = message["payload"]["line_number"]
|
if LogGroup(payload.get("group", 0)).name in self.service.exclude:
|
||||||
timestamp = message["payload"]["timestamp"]
|
continue
|
||||||
encoded_segments = message["payload"]["segments"]
|
encoded_segments = payload["segments"]
|
||||||
segments = pickle.loads(encoded_segments)
|
segments = pickle.loads(encoded_segments)
|
||||||
message_time = time()
|
message_time = time()
|
||||||
if (
|
if (
|
||||||
@@ -183,9 +195,12 @@ class ClientHandler:
|
|||||||
self.service.console.print(
|
self.service.console.print(
|
||||||
DevConsoleLog(
|
DevConsoleLog(
|
||||||
segments=segments,
|
segments=segments,
|
||||||
path=path,
|
path=payload["path"],
|
||||||
line_number=line_number,
|
line_number=payload["line_number"],
|
||||||
unix_timestamp=timestamp,
|
unix_timestamp=payload["timestamp"],
|
||||||
|
group=payload.get("group", 0),
|
||||||
|
verbosity=payload.get("verbosity", 0),
|
||||||
|
severity=payload.get("severity", 0),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
last_message_time = message_time
|
last_message_time = message_time
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ class NoParent(Exception):
|
|||||||
class DOMNode(MessagePump):
|
class DOMNode(MessagePump):
|
||||||
"""The base class for object that can be in the Textual DOM (App and Widget)"""
|
"""The base class for object that can be in the Textual DOM (App and Widget)"""
|
||||||
|
|
||||||
# Custom CSS
|
# CSS defaults
|
||||||
CSS: ClassVar[str] = ""
|
DEFAULT_CSS: ClassVar[str] = ""
|
||||||
|
|
||||||
# Default classes argument if not supplied
|
# Default classes argument if not supplied
|
||||||
DEFAULT_CLASSES: str = ""
|
DEFAULT_CLASSES: str = ""
|
||||||
@@ -198,7 +198,7 @@ class DOMNode(MessagePump):
|
|||||||
return f"{base.__name__}"
|
return f"{base.__name__}"
|
||||||
|
|
||||||
for tie_breaker, base in enumerate(self._node_bases):
|
for tie_breaker, base in enumerate(self._node_bases):
|
||||||
css = base.CSS.strip()
|
css = base.DEFAULT_CSS.strip()
|
||||||
if css:
|
if css:
|
||||||
css_stack.append((get_path(base), css, -tie_breaker))
|
css_stack.append((get_path(base), css, -tie_breaker))
|
||||||
|
|
||||||
|
|||||||
@@ -19,17 +19,17 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class Event(Message, verbosity=2):
|
class Event(Message):
|
||||||
def __rich_repr__(self) -> rich.repr.Result:
|
def __rich_repr__(self) -> rich.repr.Result:
|
||||||
return
|
return
|
||||||
yield
|
yield
|
||||||
|
|
||||||
def __init_subclass__(cls, bubble: bool = True, verbosity: int = 1) -> None:
|
def __init_subclass__(cls, bubble: bool = True, verbose: bool = False) -> None:
|
||||||
super().__init_subclass__(bubble=bubble, verbosity=verbosity)
|
super().__init_subclass__(bubble=bubble, verbose=verbose)
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class Callback(Event, bubble=False, verbosity=3):
|
class Callback(Event, bubble=False, verbose=True):
|
||||||
def __init__(
|
def __init__(
|
||||||
self, sender: MessageTarget, callback: Callable[[], Awaitable[None]]
|
self, sender: MessageTarget, callback: Callable[[], Awaitable[None]]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -40,7 +40,7 @@ class Callback(Event, bubble=False, verbosity=3):
|
|||||||
yield "callback", self.callback
|
yield "callback", self.callback
|
||||||
|
|
||||||
|
|
||||||
class InvokeCallbacks(Event, bubble=False):
|
class InvokeCallbacks(Event, bubble=False, verbose=True):
|
||||||
"""Sent after the Screen is updated"""
|
"""Sent after the Screen is updated"""
|
||||||
|
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ class Action(Event):
|
|||||||
yield "action", self.action
|
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.
|
"""Sent when the app or widget has been resized.
|
||||||
Args:
|
Args:
|
||||||
sender (MessageTarget): The sender of the event (the Screen).
|
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
|
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."""
|
"""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):
|
class Remove(Event, bubble=False):
|
||||||
"""Sent to a widget to ask it to remove itself from the DOM."""
|
"""Sent to a widget to ask it to remove itself from the DOM."""
|
||||||
|
|
||||||
@@ -217,7 +213,7 @@ class Key(InputEvent):
|
|||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class MouseEvent(InputEvent, bubble=True, verbosity=2):
|
class MouseEvent(InputEvent, bubble=True):
|
||||||
"""Sent in response to a mouse event.
|
"""Sent in response to a mouse event.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -336,21 +332,21 @@ class MouseEvent(InputEvent, bubble=True, verbosity=2):
|
|||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class MouseMove(MouseEvent, verbosity=3, bubble=True):
|
class MouseMove(MouseEvent, bubble=True, verbose=True):
|
||||||
"""Sent when the mouse cursor moves."""
|
"""Sent when the mouse cursor moves."""
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class MouseDown(MouseEvent, bubble=True):
|
class MouseDown(MouseEvent, bubble=True, verbose=True):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class MouseUp(MouseEvent, bubble=True):
|
class MouseUp(MouseEvent, bubble=True, verbose=True):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class MouseScrollDown(InputEvent, verbosity=3, bubble=True):
|
class MouseScrollDown(InputEvent, bubble=True, verbose=True):
|
||||||
__slots__ = ["x", "y"]
|
__slots__ = ["x", "y"]
|
||||||
|
|
||||||
def __init__(self, sender: MessageTarget, x: int, y: int) -> None:
|
def __init__(self, sender: MessageTarget, x: int, y: int) -> None:
|
||||||
@@ -359,7 +355,7 @@ class MouseScrollDown(InputEvent, verbosity=3, bubble=True):
|
|||||||
self.y = y
|
self.y = y
|
||||||
|
|
||||||
|
|
||||||
class MouseScrollUp(InputEvent, verbosity=3, bubble=True):
|
class MouseScrollUp(InputEvent, bubble=True, verbose=True):
|
||||||
__slots__ = ["x", "y"]
|
__slots__ = ["x", "y"]
|
||||||
|
|
||||||
def __init__(self, sender: MessageTarget, x: int, y: int) -> None:
|
def __init__(self, sender: MessageTarget, x: int, y: int) -> None:
|
||||||
@@ -373,7 +369,7 @@ class Click(MouseEvent, bubble=True):
|
|||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class Timer(Event, verbosity=3, bubble=False):
|
class Timer(Event, bubble=False, verbose=True):
|
||||||
__slots__ = ["time", "count", "callback"]
|
__slots__ = ["time", "count", "callback"]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -395,11 +391,11 @@ class Timer(Event, verbosity=3, bubble=False):
|
|||||||
yield "count", self.count
|
yield "count", self.count
|
||||||
|
|
||||||
|
|
||||||
class Enter(Event, bubble=False):
|
class Enter(Event, bubble=False, verbose=True):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Leave(Event, bubble=False):
|
class Leave(Event, bubble=False, verbose=True):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -411,11 +407,11 @@ class Blur(Event, bubble=False):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class DescendantFocus(Event, verbosity=2, bubble=True):
|
class DescendantFocus(Event, bubble=True, verbose=True):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class DescendantBlur(Event, verbosity=2, bubble=True):
|
class DescendantBlur(Event, bubble=True, verbose=True):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from .widget import Widget
|
|||||||
class Container(Widget):
|
class Container(Widget):
|
||||||
"""Simple container widget, with vertical layout."""
|
"""Simple container widget, with vertical layout."""
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Container {
|
Container {
|
||||||
layout: vertical;
|
layout: vertical;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -16,13 +16,13 @@ class Vertical(Container):
|
|||||||
"""A container widget to align children vertically."""
|
"""A container widget to align children vertically."""
|
||||||
|
|
||||||
# Blank CSS is important, otherwise you get a clone of Container
|
# Blank CSS is important, otherwise you get a clone of Container
|
||||||
CSS = ""
|
DEFAULT_CSS = ""
|
||||||
|
|
||||||
|
|
||||||
class Horizontal(Container):
|
class Horizontal(Container):
|
||||||
"""A container widget to align children horizontally."""
|
"""A container widget to align children horizontally."""
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Horizontal {
|
Horizontal {
|
||||||
layout: horizontal;
|
layout: horizontal;
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ class Horizontal(Container):
|
|||||||
class Center(Container):
|
class Center(Container):
|
||||||
"""A container widget to align children in the center."""
|
"""A container widget to align children in the center."""
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Center {
|
Center {
|
||||||
layout: center;
|
layout: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class Message:
|
|||||||
|
|
||||||
sender: MessageTarget
|
sender: MessageTarget
|
||||||
bubble: ClassVar[bool] = True # Message will bubble to parent
|
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
|
no_dispatch: ClassVar[bool] = False # Message may not be handled by client code
|
||||||
namespace: ClassVar[str] = "" # Namespace to disambiguate messages
|
namespace: ClassVar[str] = "" # Namespace to disambiguate messages
|
||||||
|
|
||||||
@@ -52,15 +52,14 @@ class Message:
|
|||||||
def __init_subclass__(
|
def __init_subclass__(
|
||||||
cls,
|
cls,
|
||||||
bubble: bool | None = True,
|
bubble: bool | None = True,
|
||||||
verbosity: int | None = 1,
|
verbose: bool = False,
|
||||||
no_dispatch: bool | None = False,
|
no_dispatch: bool | None = False,
|
||||||
namespace: str | None = None,
|
namespace: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init_subclass__()
|
super().__init_subclass__()
|
||||||
if bubble is not None:
|
if bubble is not None:
|
||||||
cls.bubble = bubble
|
cls.bubble = bubble
|
||||||
if verbosity is not None:
|
cls.verbose = verbose
|
||||||
cls.verbosity = verbosity
|
|
||||||
if no_dispatch is not None:
|
if no_dispatch is not None:
|
||||||
cls.no_dispatch = no_dispatch
|
cls.no_dispatch = no_dispatch
|
||||||
if namespace is not None:
|
if namespace is not None:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from functools import partial
|
|||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterable
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterable
|
||||||
from weakref import WeakSet
|
from weakref import WeakSet
|
||||||
|
|
||||||
from . import events, log, messages
|
from . import events, log, messages, Logger
|
||||||
from ._callback import invoke
|
from ._callback import invoke
|
||||||
from ._context import NoActiveAppError, active_app
|
from ._context import NoActiveAppError, active_app
|
||||||
from .timer import Timer, TimerCallback
|
from .timer import Timer, TimerCallback
|
||||||
@@ -110,33 +110,9 @@ class MessagePump(metaclass=MessagePumpMeta):
|
|||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
return self._running
|
return self._running
|
||||||
|
|
||||||
def log(
|
@property
|
||||||
self,
|
def log(self) -> Logger:
|
||||||
*args: Any,
|
return self.app._logger
|
||||||
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],
|
|
||||||
)
|
|
||||||
|
|
||||||
def _attach(self, parent: MessagePump) -> None:
|
def _attach(self, parent: MessagePump) -> None:
|
||||||
"""Set the parent, and therefore attach this node to the tree.
|
"""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
|
# Look through the MRO to find a handler
|
||||||
for cls, method in self._get_dispatch_methods(handler_name, message):
|
for cls, method in self._get_dispatch_methods(handler_name, message):
|
||||||
log(
|
log.event.verbosity(message.verbose)(
|
||||||
message,
|
message,
|
||||||
">>>",
|
">>>",
|
||||||
self,
|
self,
|
||||||
f"method=<{cls.__name__}.{handler_name}>",
|
f"method=<{cls.__name__}.{handler_name}>",
|
||||||
verbosity=message.verbosity,
|
|
||||||
)
|
)
|
||||||
await invoke(method, message)
|
await invoke(method, message)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class Update(Message, verbosity=3):
|
class Update(Message, verbose=True):
|
||||||
def __init__(self, sender: MessagePump, widget: Widget):
|
def __init__(self, sender: MessagePump, widget: Widget):
|
||||||
super().__init__(sender)
|
super().__init__(sender)
|
||||||
self.widget = widget
|
self.widget = widget
|
||||||
@@ -33,13 +33,13 @@ class Update(Message, verbosity=3):
|
|||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class Layout(Message, verbosity=3):
|
class Layout(Message, verbose=True):
|
||||||
def can_replace(self, message: Message) -> bool:
|
def can_replace(self, message: Message) -> bool:
|
||||||
return isinstance(message, Layout)
|
return isinstance(message, Layout)
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class InvokeLater(Message, verbosity=3):
|
class InvokeLater(Message, verbose=True):
|
||||||
def __init__(self, sender: MessagePump, callback: CallbackType) -> None:
|
def __init__(self, sender: MessagePump, callback: CallbackType) -> None:
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
super().__init__(sender)
|
super().__init__(sender)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ UPDATE_PERIOD: Final = 1 / 60
|
|||||||
class Screen(Widget):
|
class Screen(Widget):
|
||||||
"""A widget for the root of the app."""
|
"""A widget for the root of the app."""
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Screen {
|
Screen {
|
||||||
layout: vertical;
|
layout: vertical;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class ScrollView(Widget):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -87,15 +87,19 @@ class ScrollView(Widget):
|
|||||||
width, height = self.container_size
|
width, height = self.container_size
|
||||||
if self.show_vertical_scrollbar:
|
if self.show_vertical_scrollbar:
|
||||||
self.vertical_scrollbar.window_virtual_size = virtual_size.height
|
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:
|
if self.show_horizontal_scrollbar:
|
||||||
self.horizontal_scrollbar.window_virtual_size = virtual_size.width
|
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_x = self.validate_scroll_x(self.scroll_x)
|
||||||
self.scroll_y = self.validate_scroll_y(self.scroll_y)
|
self.scroll_y = self.validate_scroll_y(self.scroll_y)
|
||||||
self.refresh(layout=False)
|
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:
|
def render(self) -> RenderableType:
|
||||||
"""Render the scrollable region (if `render_lines` is not implemented).
|
"""Render the scrollable region (if `render_lines` is not implemented).
|
||||||
|
|||||||
@@ -22,26 +22,26 @@ class ScrollMessage(Message, bubble=False):
|
|||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class ScrollUp(ScrollMessage):
|
class ScrollUp(ScrollMessage, verbose=True):
|
||||||
"""Message sent when clicking above handle."""
|
"""Message sent when clicking above handle."""
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class ScrollDown(ScrollMessage):
|
class ScrollDown(ScrollMessage, verbose=True):
|
||||||
"""Message sent when clicking below handle."""
|
"""Message sent when clicking below handle."""
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class ScrollLeft(ScrollMessage):
|
class ScrollLeft(ScrollMessage, verbose=True):
|
||||||
"""Message sent when clicking above handle."""
|
"""Message sent when clicking above handle."""
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class ScrollRight(ScrollMessage):
|
class ScrollRight(ScrollMessage, verbose=True):
|
||||||
"""Message sent when clicking below handle."""
|
"""Message sent when clicking below handle."""
|
||||||
|
|
||||||
|
|
||||||
class ScrollTo(ScrollMessage):
|
class ScrollTo(ScrollMessage, verbose=True):
|
||||||
"""Message sent when click and dragging handle."""
|
"""Message sent when click and dragging handle."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -93,9 +93,9 @@ class ScrollBarRender:
|
|||||||
) -> Segments:
|
) -> Segments:
|
||||||
|
|
||||||
if vertical:
|
if vertical:
|
||||||
bars = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
|
bars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", " "]
|
||||||
else:
|
else:
|
||||||
bars = ["█", "▉", "▊", "▋", "▌", "▍", "▎", "▏", " "]
|
bars = ["▉", "▊", "▋", "▌", "▍", "▎", "▏", " "]
|
||||||
|
|
||||||
back = back_color
|
back = back_color
|
||||||
bar = bar_color
|
bar = bar_color
|
||||||
@@ -110,11 +110,11 @@ class ScrollBarRender:
|
|||||||
if window_size and size and virtual_size and size != virtual_size:
|
if window_size and size and virtual_size and size != virtual_size:
|
||||||
step_size = virtual_size / size
|
step_size = virtual_size / size
|
||||||
|
|
||||||
start = int(position / step_size * 9)
|
start = int(position / step_size * 8)
|
||||||
end = start + max(9, int(ceil(window_size / step_size * 9)))
|
end = start + max(8, int(ceil(window_size / step_size * 8)))
|
||||||
|
|
||||||
start_index, start_bar = divmod(start, 9)
|
start_index, start_bar = divmod(start, 8)
|
||||||
end_index, end_bar = divmod(end, 9)
|
end_index, end_bar = divmod(end, 8)
|
||||||
|
|
||||||
upper = {"@click": "scroll_up"}
|
upper = {"@click": "scroll_up"}
|
||||||
lower = {"@click": "scroll_down"}
|
lower = {"@click": "scroll_down"}
|
||||||
@@ -130,19 +130,23 @@ class ScrollBarRender:
|
|||||||
] * (end_index - start_index)
|
] * (end_index - start_index)
|
||||||
|
|
||||||
if start_index < len(segments):
|
if start_index < len(segments):
|
||||||
segments[start_index] = _Segment(
|
bar_character = bars[7 - start_bar]
|
||||||
bars[8 - start_bar] * width_thickness,
|
if bar_character != " ":
|
||||||
_Style(bgcolor=back, color=bar, meta=foreground_meta)
|
segments[start_index] = _Segment(
|
||||||
if vertical
|
bar_character * width_thickness,
|
||||||
else _Style(bgcolor=bar, color=back, meta=foreground_meta),
|
_Style(bgcolor=back, color=bar, meta=foreground_meta)
|
||||||
)
|
if vertical
|
||||||
|
else _Style(bgcolor=bar, color=back, meta=foreground_meta),
|
||||||
|
)
|
||||||
if end_index < len(segments):
|
if end_index < len(segments):
|
||||||
segments[end_index] = _Segment(
|
bar_character = bars[7 - end_bar]
|
||||||
bars[8 - end_bar] * width_thickness,
|
if bar_character != " ":
|
||||||
_Style(bgcolor=bar, color=back, meta=foreground_meta)
|
segments[end_index] = _Segment(
|
||||||
if vertical
|
bar_character * width_thickness,
|
||||||
else _Style(bgcolor=back, color=bar, meta=foreground_meta),
|
_Style(bgcolor=bar, color=back, meta=foreground_meta)
|
||||||
)
|
if vertical
|
||||||
|
else _Style(bgcolor=back, color=bar, meta=foreground_meta),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
style = _Style(bgcolor=back)
|
style = _Style(bgcolor=back)
|
||||||
segments = [_Segment(blank, style=style)] * int(size)
|
segments = [_Segment(blank, style=style)] * int(size)
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class Widget(DOMNode):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Widget{
|
Widget{
|
||||||
scrollbar-background: $panel-darken-1;
|
scrollbar-background: $panel-darken-1;
|
||||||
scrollbar-background-hover: $panel-darken-2;
|
scrollbar-background-hover: $panel-darken-2;
|
||||||
@@ -1248,16 +1248,19 @@ class Widget(DOMNode):
|
|||||||
width, height = self.container_size
|
width, height = self.container_size
|
||||||
if self.show_vertical_scrollbar:
|
if self.show_vertical_scrollbar:
|
||||||
self.vertical_scrollbar.window_virtual_size = virtual_size.height
|
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:
|
if self.show_horizontal_scrollbar:
|
||||||
self.horizontal_scrollbar.window_virtual_size = virtual_size.width
|
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_x = self.validate_scroll_x(self.scroll_x)
|
||||||
self.scroll_y = self.validate_scroll_y(self.scroll_y)
|
self.scroll_y = self.validate_scroll_y(self.scroll_y)
|
||||||
self.refresh(layout=True)
|
self.refresh(layout=True)
|
||||||
self.scroll_to(self.scroll_x, self.scroll_y)
|
self.scroll_to(self.scroll_x, self.scroll_y)
|
||||||
# self.call_later(self.scroll_to, self.scroll_x, self.scroll_y)
|
|
||||||
else:
|
else:
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
@@ -1396,7 +1399,7 @@ class Widget(DOMNode):
|
|||||||
if not self.check_message_enabled(message):
|
if not self.check_message_enabled(message):
|
||||||
return True
|
return True
|
||||||
if not self.is_running:
|
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)
|
return await super().post_message(message)
|
||||||
|
|
||||||
async def _on_idle(self, event: events.Idle) -> None:
|
async def _on_idle(self, event: events.Idle) -> None:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class InvalidButtonVariant(Exception):
|
|||||||
class Button(Widget, can_focus=True):
|
class Button(Widget, can_focus=True):
|
||||||
"""A simple clickable button."""
|
"""A simple clickable button."""
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Button {
|
Button {
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 10;
|
min-width: 10;
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ class Coord(NamedTuple):
|
|||||||
|
|
||||||
class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
DataTable {
|
DataTable {
|
||||||
background: $surface;
|
background: $surface;
|
||||||
color: $text-surface;
|
color: $text-surface;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from ..widget import Widget
|
|||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class Footer(Widget):
|
class Footer(Widget):
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Footer {
|
Footer {
|
||||||
background: $accent;
|
background: $accent;
|
||||||
color: $text-accent;
|
color: $text-accent;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from ..reactive import Reactive, watch
|
|||||||
class HeaderIcon(Widget):
|
class HeaderIcon(Widget):
|
||||||
"""Display an 'icon' on the left of the header."""
|
"""Display an 'icon' on the left of the header."""
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
HeaderIcon {
|
HeaderIcon {
|
||||||
dock: left;
|
dock: left;
|
||||||
padding: 0 1;
|
padding: 0 1;
|
||||||
@@ -28,7 +28,7 @@ class HeaderIcon(Widget):
|
|||||||
class HeaderClock(Widget):
|
class HeaderClock(Widget):
|
||||||
"""Display a clock on the right of the header."""
|
"""Display a clock on the right of the header."""
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
HeaderClock {
|
HeaderClock {
|
||||||
dock: right;
|
dock: right;
|
||||||
width: auto;
|
width: auto;
|
||||||
@@ -50,7 +50,7 @@ class HeaderClock(Widget):
|
|||||||
class HeaderTitle(Widget):
|
class HeaderTitle(Widget):
|
||||||
"""Display the title / subtitle in the header."""
|
"""Display the title / subtitle in the header."""
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
HeaderTitle {
|
HeaderTitle {
|
||||||
content-align: center middle;
|
content-align: center middle;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -70,7 +70,7 @@ class HeaderTitle(Widget):
|
|||||||
class Header(Widget):
|
class Header(Widget):
|
||||||
"""A header widget with icon and clock."""
|
"""A header widget with icon and clock."""
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Header {
|
Header {
|
||||||
dock: top;
|
dock: top;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from ..widget import Widget
|
|||||||
|
|
||||||
|
|
||||||
class Pretty(Widget):
|
class Pretty(Widget):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Static {
|
Static {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def _check_renderable(renderable: object):
|
|||||||
|
|
||||||
|
|
||||||
class Static(Widget):
|
class Static(Widget):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Static {
|
Static {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ class TreeClick(Generic[NodeDataType], Message, bubble=True):
|
|||||||
|
|
||||||
|
|
||||||
class TreeControl(Generic[NodeDataType], Widget, can_focus=True):
|
class TreeControl(Generic[NodeDataType], Widget, can_focus=True):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
TreeControl {
|
TreeControl {
|
||||||
background: $panel;
|
background: $panel;
|
||||||
color: $text-panel;
|
color: $text-panel;
|
||||||
|
|||||||
@@ -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.
|
suggestion will be displayed as dim text similar to suggestion text in the zsh or fish shells.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
TextInput {
|
TextInput {
|
||||||
width: auto;
|
width: auto;
|
||||||
background: $surface;
|
background: $surface;
|
||||||
@@ -417,7 +417,7 @@ class TextInput(TextWidgetBase, can_focus=True):
|
|||||||
|
|
||||||
|
|
||||||
class TextArea(Widget):
|
class TextArea(Widget):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
TextArea { overflow: auto auto; height: 5; background: $primary-darken-1; }
|
TextArea { overflow: auto auto; height: 5; background: $primary-darken-1; }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -428,7 +428,7 @@ class TextArea(Widget):
|
|||||||
class TextAreaChild(TextWidgetBase, can_focus=True):
|
class TextAreaChild(TextWidgetBase, can_focus=True):
|
||||||
# TODO: Not nearly ready for prime-time, but it exists to help
|
# TODO: Not nearly ready for prime-time, but it exists to help
|
||||||
# model the superclass.
|
# 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"}
|
STOP_PROPAGATE = {"tab", "shift+tab"}
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
|
|||||||
@@ -106,16 +106,18 @@ def test_stylesheet_apply_user_css_over_widget_css():
|
|||||||
user_css = ".a {color: red; tint: yellow;}"
|
user_css = ".a {color: red; tint: yellow;}"
|
||||||
|
|
||||||
class MyWidget(Widget):
|
class MyWidget(Widget):
|
||||||
CSS = ".a {color: blue !important; background: lime;}"
|
DEFAULT_CSS = ".a {color: blue !important; background: lime;}"
|
||||||
|
|
||||||
node = MyWidget()
|
node = MyWidget()
|
||||||
node.add_class("a")
|
node.add_class("a")
|
||||||
|
|
||||||
stylesheet = _make_user_stylesheet(user_css)
|
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)
|
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)
|
assert node.styles.color == Color(255, 0, 0)
|
||||||
# The background colour defined in the Widget still applies, since user CSS doesn't override it
|
# The background colour defined in the Widget still applies, since user CSS doesn't override it
|
||||||
assert node.styles.background == Color(0, 255, 0)
|
assert node.styles.background == Color(0, 255, 0)
|
||||||
|
|||||||
@@ -27,8 +27,3 @@ async def devtools(aiohttp_client, server):
|
|||||||
yield devtools
|
yield devtools
|
||||||
await devtools.disconnect()
|
await devtools.disconnect()
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def in_memory_logfile():
|
|
||||||
yield StringIO()
|
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ def test_log_message_render(console):
|
|||||||
path="abc/hello.py",
|
path="abc/hello.py",
|
||||||
line_number=123,
|
line_number=123,
|
||||||
unix_timestamp=TIMESTAMP,
|
unix_timestamp=TIMESTAMP,
|
||||||
|
group=0,
|
||||||
|
verbosity=0,
|
||||||
|
severity=0,
|
||||||
)
|
)
|
||||||
table = next(iter(message.__rich_console__(console, console.options)))
|
table = next(iter(message.__rich_console__(console, console.options)))
|
||||||
|
|
||||||
@@ -52,7 +55,7 @@ def test_log_message_render(console):
|
|||||||
local_time = datetime.fromtimestamp(TIMESTAMP)
|
local_time = datetime.fromtimestamp(TIMESTAMP)
|
||||||
string_timestamp = local_time.time()
|
string_timestamp = local_time.time()
|
||||||
|
|
||||||
assert left == f"[dim]{string_timestamp}"
|
assert left.plain == f"[{string_timestamp}] UNDEFINED"
|
||||||
assert right.align == "right"
|
assert right.align == "right"
|
||||||
assert "hello.py:123" in right.renderable
|
assert "hello.py:123" in right.renderable
|
||||||
|
|
||||||
|
|||||||
@@ -36,15 +36,20 @@ async def test_devtools_log_places_encodes_and_queues_message(devtools):
|
|||||||
queued_log = await devtools.log_queue.get()
|
queued_log = await devtools.log_queue.get()
|
||||||
queued_log_data = msgpack.unpackb(queued_log)
|
queued_log_data = msgpack.unpackb(queued_log)
|
||||||
print(repr(queued_log_data))
|
print(repr(queued_log_data))
|
||||||
assert queued_log_data == {
|
|
||||||
"type": "client_log",
|
|
||||||
"payload": {
|
{
|
||||||
"timestamp": 1649166819,
|
"type": "client_log",
|
||||||
"path": "a/b/c.py",
|
"payload": {
|
||||||
"line_number": 123,
|
"group": 0,
|
||||||
"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.",
|
"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))
|
@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 == {
|
assert queued_log_data == {
|
||||||
"type": "client_log",
|
"type": "client_log",
|
||||||
"payload": {
|
"payload": {
|
||||||
|
"group": 0,
|
||||||
|
"verbosity": 0,
|
||||||
|
"severity": 0,
|
||||||
"timestamp": 1649166819,
|
"timestamp": 1649166819,
|
||||||
"path": "a/b/c.py",
|
"path": "a/b/c.py",
|
||||||
"line_number": 123,
|
"line_number": 123,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ TIMESTAMP = 1649166819
|
|||||||
async def test_print_redirect_to_devtools_only(devtools):
|
async def test_print_redirect_to_devtools_only(devtools):
|
||||||
await devtools._stop_log_queue_processing()
|
await devtools._stop_log_queue_processing()
|
||||||
|
|
||||||
with redirect_stdout(StdoutRedirector(devtools, None)): # type: ignore
|
with redirect_stdout(StdoutRedirector(devtools)): # type: ignore
|
||||||
print("Hello, world!")
|
print("Hello, world!")
|
||||||
|
|
||||||
assert devtools.log_queue.qsize() == 1
|
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()
|
await devtools.disconnect()
|
||||||
with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)): # type: ignore
|
with redirect_stdout(StdoutRedirector(devtools)): # type: ignore
|
||||||
print("Hello, world!")
|
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()
|
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!")
|
print("Hello, world!")
|
||||||
|
|
||||||
assert devtools.log_queue.qsize() == 1
|
assert devtools.log_queue.qsize() == 1
|
||||||
assert in_memory_logfile.getvalue() == "Hello, world!\n"
|
|
||||||
|
|
||||||
|
|
||||||
@time_machine.travel(datetime.fromtimestamp(TIMESTAMP))
|
@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()
|
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
|
# End is no longer newline character, so print will no longer
|
||||||
# flush the output buffer by default.
|
# flush the output buffer by default.
|
||||||
print("Hello, world!", end="")
|
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))
|
@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()
|
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)
|
print("Hello, world!", end="", flush=True)
|
||||||
|
|
||||||
assert devtools.log_queue.qsize() == 1
|
assert devtools.log_queue.qsize() == 1
|
||||||
|
|
||||||
|
|
||||||
@time_machine.travel(datetime.fromtimestamp(TIMESTAMP))
|
@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()
|
await devtools._stop_log_queue_processing()
|
||||||
|
redirector = StdoutRedirector(devtools)
|
||||||
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)
|
|
||||||
with redirect_stdout(redirector): # type: ignore
|
with redirect_stdout(redirector): # type: ignore
|
||||||
# This print adds 3 messages to the buffer that can be batched
|
# This print adds 3 messages to the buffer that can be batched
|
||||||
print("The first", "batch", "of logs", end="")
|
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))
|
@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()
|
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
|
# Flushing is disabled since end="", but the first
|
||||||
# string will be flushed since it contains a newline
|
# string will be flushed since it contains a newline
|
||||||
print("Hel\nlo", end="")
|
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))
|
@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()
|
await devtools._stop_log_queue_processing()
|
||||||
|
|
||||||
redirector = StdoutRedirector(devtools, in_memory_logfile)
|
redirector = StdoutRedirector(devtools)
|
||||||
with redirect_stdout(redirector): # type: ignore
|
with redirect_stdout(redirector): # type: ignore
|
||||||
print("x", end="")
|
print("x", end="")
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ async def test_composition_of_vertical_container_with_children(
|
|||||||
expected_placeholders_offset_x: int,
|
expected_placeholders_offset_x: int,
|
||||||
):
|
):
|
||||||
class VerticalContainer(Widget):
|
class VerticalContainer(Widget):
|
||||||
CSS = (
|
DEFAULT_CSS = (
|
||||||
"""
|
"""
|
||||||
VerticalContainer {
|
VerticalContainer {
|
||||||
layout: vertical;
|
layout: vertical;
|
||||||
@@ -304,7 +304,7 @@ async def test_scrollbar_size_impact_on_the_layout(
|
|||||||
class LargeWidgetContainer(Widget):
|
class LargeWidgetContainer(Widget):
|
||||||
# TODO: Once textual#581 ("Default versus User CSS") is solved the following CSS should just use the
|
# 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:
|
# "LargeWidgetContainer" selector, without having to use a more specific one to be able to override Widget's CSS:
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
#large-widget-container {
|
#large-widget-container {
|
||||||
width: 20;
|
width: 20;
|
||||||
height: 20;
|
height: 20;
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ async def test_scroll_to_widget(
|
|||||||
last_screen_expected_placeholder_ids: Sequence[int],
|
last_screen_expected_placeholder_ids: Sequence[int],
|
||||||
):
|
):
|
||||||
class VerticalContainer(Widget):
|
class VerticalContainer(Widget):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
VerticalContainer {
|
VerticalContainer {
|
||||||
layout: vertical;
|
layout: vertical;
|
||||||
overflow: hidden auto;
|
overflow: hidden auto;
|
||||||
@@ -60,7 +60,7 @@ async def test_scroll_to_widget(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
class MyTestApp(AppTest):
|
class MyTestApp(AppTest):
|
||||||
CSS = """
|
DEFAULT_CSS = """
|
||||||
Placeholder {
|
Placeholder {
|
||||||
height: 5; /* minimal height to see the name of a Placeholder */
|
height: 5; /* minimal height to see the name of a Placeholder */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,20 +25,11 @@ from textual.geometry import Size, Region
|
|||||||
|
|
||||||
|
|
||||||
class AppTest(App):
|
class AppTest(App):
|
||||||
def __init__(
|
def __init__(self, *, test_name: str, size: Size):
|
||||||
self,
|
|
||||||
*,
|
|
||||||
test_name: str,
|
|
||||||
size: Size,
|
|
||||||
log_verbosity: int = 2,
|
|
||||||
):
|
|
||||||
# Tests will log in "/tests/test.[test name].log":
|
# Tests will log in "/tests/test.[test name].log":
|
||||||
log_path = Path(__file__).parent.parent / f"test.{test_name}.log"
|
log_path = Path(__file__).parent.parent / f"test.{test_name}.log"
|
||||||
super().__init__(
|
super().__init__(
|
||||||
driver_class=DriverTest,
|
driver_class=DriverTest,
|
||||||
log_path=log_path,
|
|
||||||
log_verbosity=log_verbosity,
|
|
||||||
log_color_system="256",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Let's disable all features by default
|
# Let's disable all features by default
|
||||||
|
|||||||
Reference in New Issue
Block a user