diff --git a/docs/api/text_log.md b/docs/api/text_log.md new file mode 100644 index 0000000000..07aa5420dd --- /dev/null +++ b/docs/api/text_log.md @@ -0,0 +1 @@ +::: textual.widgets.TextLog diff --git a/docs/examples/widgets/text_log.py b/docs/examples/widgets/text_log.py new file mode 100644 index 0000000000..c1f5056383 --- /dev/null +++ b/docs/examples/widgets/text_log.py @@ -0,0 +1,66 @@ +import csv +import io + +from rich.table import Table +from rich.syntax import Syntax + +from textual.app import App, ComposeResult +from textual import events +from textual.widgets import TextLog + + +CSV = """lane,swimmer,country,time +4,Joseph Schooling,Singapore,50.39 +2,Michael Phelps,United States,51.14 +5,Chad le Clos,South Africa,51.14 +6,László Cseh,Hungary,51.14 +3,Li Zhuhao,China,51.26 +8,Mehdy Metella,France,51.58 +7,Tom Shields,United States,51.73 +1,Aleksandr Sadovnikov,Russia,51.84""" + + +CODE = '''\ +def loop_first_last(values: Iterable[T]) -> Iterable[tuple[bool, bool, T]]: + """Iterate and generate a tuple with a flag for first and last value.""" + iter_values = iter(values) + try: + previous_value = next(iter_values) + except StopIteration: + return + first = True + for value in iter_values: + yield first, False, previous_value + first = False + previous_value = value + yield first, True, previous_value\ +''' + + +class TextLogApp(App): + def compose(self) -> ComposeResult: + yield TextLog(highlight=True, markup=True) + + def on_ready(self) -> None: + """Called when the DOM is ready.""" + text_log = self.query_one(TextLog) + + text_log.write(Syntax(CODE, "python", indent_guides=True)) + + rows = iter(csv.reader(io.StringIO(CSV))) + table = Table(*next(rows)) + for row in rows: + table.add_row(*row) + + text_log.write(table) + text_log.write("[bold magenta]Write text or any Rich renderable!") + + def on_key(self, event: events.Key) -> None: + """Write Key events to log.""" + text_log = self.query_one(TextLog) + text_log.write(event) + + +if __name__ == "__main__": + app = TextLogApp() + app.run() diff --git a/docs/widgets/text_log.md b/docs/widgets/text_log.md new file mode 100644 index 0000000000..8a8f690d8d --- /dev/null +++ b/docs/widgets/text_log.md @@ -0,0 +1,44 @@ +# TextLog + +A TextLog is a widget which displays scrollable content that may be appended to in realtime. + +Call [TextLog.write][textual.widgets.TextLog.write] with a string or [Rich Renderable](https://rich.readthedocs.io/en/latest/protocol.html) to write content to the end of the TextLog. Call [TextLog.clear][textual.widgets.TextLog.clear] to clear the content. + +- [X] Focusable +- [ ] Container + +## Example + +The example below shows each placeholder variant. + +=== "Output" + + ```{.textual path="docs/examples/widgets/text_log.py" press="_,H,i"} + ``` + +=== "text_log.py" + + ```python + --8<-- "docs/examples/widgets/text_log.py" + ``` + + + +## Reactive Attributes + +| Name | Type | Default | Description | +| ----------- | ------ | ------- | ------------------------------------------------------------ | +| `highlight` | `bool` | `False` | Automatically highlight content. | +| `markup` | `bool` | `False` | Apply Rich console markup. | +| `max_lines` | `int` | `None` | Maximum number of lines in the log or `None` for no maximum. | +| `min_width` | `int` | 78 | Minimum width of renderables. | +| `wrap` | `bool` | `False` | Enable word wrapping. | + +## Messages + +This widget sends no messages. + + +## See Also + +* [TextLog](../api/textlog.md) code reference diff --git a/mkdocs.yml b/mkdocs.yml index b8a1df2fdb..a60b48e900 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -92,6 +92,7 @@ nav: - "widgets/button.md" - "widgets/checkbox.md" - "widgets/data_table.md" + - "widgets/text_log.md" - "widgets/directory_tree.md" - "widgets/footer.md" - "widgets/header.md" @@ -109,6 +110,7 @@ nav: - "api/color.md" - "api/containers.md" - "api/data_table.md" + - "api/text_log.md" - "api/directory_tree.md" - "api/dom_node.md" - "api/events.md" diff --git a/src/textual/app.py b/src/textual/app.py index 3695834278..db36202d1f 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -356,6 +356,7 @@ def __init__( ) self._screenshot: str | None = None self._dom_lock = asyncio.Lock() + self._dom_ready = False @property def return_value(self) -> ReturnType | None: diff --git a/src/textual/events.py b/src/textual/events.py index 815f75f020..912613e195 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -140,6 +140,10 @@ class Hide(Event, bubble=False): """ +class Ready(Event, bubble=False): + """Sent to the app when the DOM is ready.""" + + @rich.repr.auto class MouseCapture(Event, bubble=False): """Sent when the mouse has been captured. diff --git a/src/textual/screen.py b/src/textual/screen.py index 2fd22308af..53650cb081 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -379,8 +379,6 @@ def _refresh_layout(self, size: Size | None = None, full: bool = False) -> None: for widget in hidden: widget.post_message_no_wait(Hide(self)) - for widget in shown: - widget.post_message_no_wait(Show(self)) # We want to send a resize event to widgets that were just added or change since last layout send_resize = shown | resized @@ -401,11 +399,17 @@ def _refresh_layout(self, size: Size | None = None, full: bool = False) -> None: ResizeEvent(self, region.size, virtual_size, container_size) ) + for widget in shown: + widget.post_message_no_wait(Show(self)) + except Exception as error: self.app._handle_exception(error) return display_update = self._compositor.render(full=full) self.app._display(self, display_update) + if not self.app._dom_ready: + self.app.post_message_no_wait(events.Ready(self)) + self.app._dom_ready = True async def _on_update(self, message: messages.Update) -> None: message.stop() diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py index 7041a1dfeb..1ce878cb34 100644 --- a/src/textual/widgets/_text_log.py +++ b/src/textual/widgets/_text_log.py @@ -4,6 +4,7 @@ from rich.console import RenderableType from rich.highlighter import ReprHighlighter +from rich.measure import measure_renderables from rich.pretty import Pretty from rich.protocol import is_renderable from rich.segment import Segment @@ -73,22 +74,31 @@ def write(self, content: RenderableType | object) -> None: else: if isinstance(content, str): if self.markup: - content = Text.from_markup(content) - if self.highlight: - renderable = self.highlighter(content) + renderable = Text.from_markup(content) else: renderable = Text(content) + if self.highlight: + renderable = self.highlighter(content) else: renderable = cast(RenderableType, content) console = self.app.console - width = max(self.min_width, self.size.width or self.min_width) + render_options = console.options - render_options = console.options.update_width(width) - if not self.wrap: + if isinstance(renderable, Text) and not self.wrap: render_options = render_options.update(overflow="ignore", no_wrap=True) - segments = self.app.console.render(renderable, render_options.update_width(80)) + + width = max( + self.min_width, + measure_renderables(console, render_options, [renderable]).maximum, + ) + + segments = self.app.console.render( + renderable, render_options.update_width(width) + ) lines = list(Segment.split_lines(segments)) + if not lines: + return self.max_width = max( self.max_width,