From 2fc9832d754249d047e21ecaf5de3fb23cb92937 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 24 Oct 2024 20:09:37 +0100 Subject: [PATCH 1/9] logs demo --- src/textual/demo2/widgets.py | 118 ++++++++++++++++++++++++++++++- src/textual/widget.py | 23 ++++-- src/textual/widgets/_log.py | 20 ++++-- src/textual/widgets/_rich_log.py | 11 ++- 4 files changed, 157 insertions(+), 15 deletions(-) diff --git a/src/textual/demo2/widgets.py b/src/textual/demo2/widgets.py index 6a1eded388..a18c89d40e 100644 --- a/src/textual/demo2/widgets.py +++ b/src/textual/demo2/widgets.py @@ -1,7 +1,15 @@ +import csv +import io + +from rich.syntax import Syntax +from rich.table import Table +from rich.traceback import Traceback + from textual import containers from textual.app import ComposeResult from textual.demo2.data import COUNTRIES from textual.demo2.page import PageScreen +from textual.reactive import var from textual.suggester import SuggestFromList from textual.widgets import ( Button, @@ -13,11 +21,14 @@ Label, ListItem, ListView, + Log, Markdown, MaskedInput, OptionList, RadioButton, RadioSet, + RichLog, + TabbedContent, ) WIDGETS_MD = """\ @@ -219,7 +230,7 @@ class ListViews(containers.VerticalGroup): """ - DEFAULT_CSS = """\ + DEFAULT_CSS = """ ListViews { ListView { width: 1fr; @@ -244,6 +255,110 @@ def compose(self) -> ComposeResult: yield OptionList(*COUNTRIES) +class Logs(containers.VerticalGroup): + DEFAULT_CLASSES = "column" + LOGS_MD = """\ +## Logs and Rich Logs + +A Log widget to efficiently display a scrolling view of text. +And a RichLog widget to display Rich renderables. + +""" + DEFAULT_CSS = """ + Logs { + Log, RichLog { + width: 1fr; + height: 20; + border: blank; + padding: 0; + overflow-x: auto; + &:focus { + border: heavy $accent; + } + } + TabPane { padding: 0; } + } + """ + + TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""".splitlines() + + 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""" + CSV_ROWS = list(csv.reader(io.StringIO(CSV))) + + 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\ +''' + log_count = var(0) + rich_log_count = var(0) + + def compose(self) -> ComposeResult: + yield Markdown(self.LOGS_MD) + with TabbedContent("Log", "RichLog"): + yield Log(max_lines=10_000) + yield RichLog(max_lines=10_000) + + def on_mount(self) -> None: + log = self.query_one(Log) + rich_log = self.query_one(RichLog) + log.write("I am a Log Widget") + rich_log.write("I am a [b]Rich Log Widget") + self.set_interval(0.25, self.update_log) + self.set_interval(1, self.update_rich_log) + + def update_log(self) -> None: + if not self.screen.can_view(self): + return + self.log_count += 1 + self.query_one(Log).write_line(self.TEXT[self.log_count % len(self.TEXT)]) + + def update_rich_log(self) -> None: + rich_log = self.query_one(RichLog) + self.rich_log_count += 1 + log_option = self.rich_log_count % 3 + if log_option == 0: + rich_log.write("Syntax highlighted code") + rich_log.write(Syntax(self.CODE, lexer="python"), animate=True) + elif log_option == 1: + rich_log.write("A Rich Table") + table = Table(*self.CSV_ROWS[0]) + for row in self.CSV_ROWS[1:]: + table.add_row(*row) + rich_log.write(table, animate=True) + elif log_option == 2: + rich_log.write("A Rich Traceback") + try: + 1 / 0 + except Exception: + traceback = Traceback() + rich_log.write(traceback, animate=True) + + class WidgetsScreen(PageScreen): CSS = """ WidgetsScreen { @@ -266,6 +381,7 @@ def compose(self) -> ComposeResult: yield Datatables() yield Inputs() yield ListViews() + yield Logs() yield Footer() def action_unfocus(self) -> None: diff --git a/src/textual/widget.py b/src/textual/widget.py index e6b124f191..1fe04aedf8 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2480,6 +2480,8 @@ def scroll_home( on_complete: CallbackType | None = None, level: AnimationLevel = "basic", immediate: bool = False, + x_axis: bool = True, + y_axis: bool = True, ) -> None: """Scroll to home position. @@ -2493,12 +2495,14 @@ def scroll_home( level: Minimum level required for the animation to take place (inclusive). immediate: If `False` the scroll will be deferred until after a screen refresh, set to `True` to scroll immediately. + x_axis: Allow scrolling on X axis? + y_axis: Allow scrolling on Y axis? """ if speed is None and duration is None: duration = 1.0 self.scroll_to( - 0, - 0, + 0 if x_axis else None, + 0 if y_axis else None, animate=animate, speed=speed, duration=duration, @@ -2520,6 +2524,8 @@ def scroll_end( on_complete: CallbackType | None = None, level: AnimationLevel = "basic", immediate: bool = False, + x_axis: bool = True, + y_axis: bool = True, ) -> None: """Scroll to the end of the container. @@ -2533,6 +2539,9 @@ def scroll_end( level: Minimum level required for the animation to take place (inclusive). immediate: If `False` the scroll will be deferred until after a screen refresh, set to `True` to scroll immediately. + x_axis: Allow scrolling on X axis? + y_axis: Allow scrolling on Y axis? + """ if speed is None and duration is None: duration = 1.0 @@ -2546,8 +2555,8 @@ def scroll_end( def _lazily_scroll_end() -> None: """Scroll to the end of the widget.""" self._scroll_to( - 0, - self.max_scroll_y, + 0 if x_axis else None, + self.max_scroll_y if y_axis else None, animate=animate, speed=speed, duration=duration, @@ -3313,7 +3322,7 @@ def can_view(self, widget: Widget) -> bool: node: Widget = widget while isinstance(node.parent, Widget) and node is not self: - if region not in node.parent.scrollable_content_region: + if not region.overlaps(node.parent.scrollable_content_region): return False node = node.parent return True @@ -4210,13 +4219,13 @@ def action_scroll_home(self) -> None: if not self._allow_scroll: raise SkipAction() self._clear_anchor() - self.scroll_home() + self.scroll_home(x_axis=self.scroll_y == 0) def action_scroll_end(self) -> None: if not self._allow_scroll: raise SkipAction() self._clear_anchor() - self.scroll_end() + self.scroll_end(x_axis=self.scroll_y == self.is_vertical_scroll_end) def action_scroll_left(self) -> None: if not self.allow_horizontal_scroll: diff --git a/src/textual/widgets/_log.py b/src/textual/widgets/_log.py index ee50fd87ed..ad6bf616c2 100644 --- a/src/textual/widgets/_log.py +++ b/src/textual/widgets/_log.py @@ -163,7 +163,8 @@ def write( Returns: The `Log` instance. """ - + is_vertical_scroll_end = self.is_vertical_scroll_end + print(self.scroll_offset.y, self.max_scroll_y) if data: if not self._lines: self._lines.append("") @@ -181,8 +182,12 @@ def write( self._prune_max_lines() auto_scroll = self.auto_scroll if scroll_end is None else scroll_end - if auto_scroll and not self.is_vertical_scrollbar_grabbed: - self.scroll_end(animate=False) + if ( + auto_scroll + and not self.is_vertical_scrollbar_grabbed + and self.is_vertical_scroll_end + ): + self.scroll_end(animate=False, immediate=True, x_axis=False) return self def write_line(self, line: str) -> Self: @@ -211,6 +216,7 @@ def write_lines( Returns: The `Log` instance. """ + is_vertical_scroll_end = self.is_vertical_scroll_end auto_scroll = self.auto_scroll if scroll_end is None else scroll_end new_lines = [] for line in lines: @@ -222,8 +228,12 @@ def write_lines( self.virtual_size = Size(self._width, len(self._lines)) self._update_size(self._updates, new_lines) self.refresh_lines(start_line, len(new_lines)) - if auto_scroll and not self.is_vertical_scrollbar_grabbed: - self.scroll_end(animate=False) + if ( + auto_scroll + and not self.is_vertical_scrollbar_grabbed + and is_vertical_scroll_end + ): + self.scroll_end(animate=False, immediate=True, x_axis=False) else: self.refresh() return self diff --git a/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index d7aa4bd3e4..a974503058 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -169,6 +169,7 @@ def write( expand: bool = False, shrink: bool = True, scroll_end: bool | None = None, + animate: bool = False, ) -> Self: """Write a string or a Rich renderable to the bottom of the log. @@ -186,6 +187,7 @@ def write( shrink: Permit shrinking of content to fit within the content region of the RichLog. If `width` is specified, then `shrink` will be ignored. scroll_end: Enable automatic scroll to end, or `None` to use `self.auto_scroll`. + animate: Enable animation if the log will scroll. Returns: The `RichLog` instance. @@ -200,6 +202,7 @@ def write( ) return self + is_vertical_scroll_end = self.is_vertical_scroll_end renderable = self._make_renderable(content) auto_scroll = self.auto_scroll if scroll_end is None else scroll_end @@ -266,8 +269,12 @@ def write( # the new line(s), and the height will definitely have changed. self.virtual_size = Size(self._widest_line_width, len(self.lines)) - if auto_scroll: - self.scroll_end(animate=False) + if ( + auto_scroll + and not self.is_vertical_scrollbar_grabbed + and is_vertical_scroll_end + ): + self.scroll_end(animate=animate, immediate=True, x_axis=False) return self From 17fad115fee367974880019c8f2b2f6f61867c6f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 25 Oct 2024 13:01:23 +0100 Subject: [PATCH 2/9] sparklines --- CHANGELOG.md | 1 + src/textual/demo2/widgets.py | 69 ++++++++++++++++++++++++++++--- src/textual/widgets/_log.py | 3 +- src/textual/widgets/_sparkline.py | 25 ++++++++--- 4 files changed, 85 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ae4b5d98..0532cd9139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `Button.action` parameter to invoke action when clicked https://github.com/Textualize/textual/pull/5113 - Added `immediate` parameter to scroll methods https://github.com/Textualize/textual/pull/5164 - Added `textual._loop.loop_from_index` https://github.com/Textualize/textual/pull/5164 +- Added `min_color` and `max_color` to Sparklines constructor, which take precedence over CSS ### Fixed diff --git a/src/textual/demo2/widgets.py b/src/textual/demo2/widgets.py index a18c89d40e..597d862059 100644 --- a/src/textual/demo2/widgets.py +++ b/src/textual/demo2/widgets.py @@ -1,5 +1,6 @@ import csv import io +from math import sin from rich.syntax import Syntax from rich.table import Table @@ -9,7 +10,7 @@ from textual.app import ComposeResult from textual.demo2.data import COUNTRIES from textual.demo2.page import PageScreen -from textual.reactive import var +from textual.reactive import reactive, var from textual.suggester import SuggestFromList from textual.widgets import ( Button, @@ -28,6 +29,7 @@ RadioButton, RadioSet, RichLog, + Sparkline, TabbedContent, ) @@ -260,7 +262,7 @@ class Logs(containers.VerticalGroup): LOGS_MD = """\ ## Logs and Rich Logs -A Log widget to efficiently display a scrolling view of text. +A Log widget to efficiently display a scrolling view of text, with optional highlighted. And a RichLog widget to display Rich renderables. """ @@ -320,7 +322,7 @@ def loop_first_last(values: Iterable[T]) -> Iterable[tuple[bool, bool, T]]: def compose(self) -> ComposeResult: yield Markdown(self.LOGS_MD) with TabbedContent("Log", "RichLog"): - yield Log(max_lines=10_000) + yield Log(max_lines=10_000, highlight=True) yield RichLog(max_lines=10_000) def on_mount(self) -> None: @@ -332,12 +334,17 @@ def on_mount(self) -> None: self.set_interval(1, self.update_rich_log) def update_log(self) -> None: - if not self.screen.can_view(self): + if not self.screen.can_view(self) or not self.screen.is_active: return self.log_count += 1 - self.query_one(Log).write_line(self.TEXT[self.log_count % len(self.TEXT)]) + log = self.query_one(Log) + line_no = self.log_count % len(self.TEXT) + line = self.TEXT[self.log_count % len(self.TEXT)] + log.write_line(f"fear[{line_no}] = {line!r}") def update_rich_log(self) -> None: + if not self.screen.can_view(self) or not self.screen.is_active: + return rich_log = self.query_one(RichLog) self.rich_log_count += 1 log_option = self.rich_log_count % 3 @@ -359,6 +366,57 @@ def update_rich_log(self) -> None: rich_log.write(traceback, animate=True) +class Sparklines(containers.VerticalGroup): + DEFAULT_CLASSES = "column" + LOGS_MD = """\ +## Sparklines + +A low-res summary of time-series data. + +For detailed graphs, see [textual-plotext](https://github.com/Textualize/textual-plotext). +""" + DEFAULT_CSS = """ + Sparklines { + Sparkline { + width: 1fr; + margin: 1; + & #first > .sparkline--min-color { color: $success; } + & #first > .sparkline--max-color { color: $warning; } + & #second > .sparkline--min-color { color: $warning; } + & #second > .sparkline--max-color { color: $error; } + & #third > .sparkline--min-color { color: $primary; } + & #third > .sparkline--max-color { color: $accent; } + } + } + + """ + + count = var(0) + data: reactive[list[float]] = reactive(list) + + def compose(self) -> ComposeResult: + yield Markdown(self.LOGS_MD) + yield Sparkline([], summary_function=max, id="first").data_bind( + Sparklines.data, + ) + yield Sparkline([], summary_function=max, id="second").data_bind( + Sparklines.data + ) + yield Sparkline([], summary_function=max, id="third").data_bind( + Sparklines.data, + ) + + def on_mount(self) -> None: + self.set_interval(0.1, self.update_sparks) + + def update_sparks(self) -> None: + if not self.screen.can_view(self) or not self.screen.is_active: + return + self.count += 1 + offset = self.count * 40 + self.data = [abs(sin(x / 3.14)) for x in range(offset, offset + 360 * 6, 20)] + + class WidgetsScreen(PageScreen): CSS = """ WidgetsScreen { @@ -382,6 +440,7 @@ def compose(self) -> ComposeResult: yield Inputs() yield ListViews() yield Logs() + yield Sparklines() yield Footer() def action_unfocus(self) -> None: diff --git a/src/textual/widgets/_log.py b/src/textual/widgets/_log.py index ad6bf616c2..0441a2c74e 100644 --- a/src/textual/widgets/_log.py +++ b/src/textual/widgets/_log.py @@ -164,7 +164,6 @@ def write( The `Log` instance. """ is_vertical_scroll_end = self.is_vertical_scroll_end - print(self.scroll_offset.y, self.max_scroll_y) if data: if not self._lines: self._lines.append("") @@ -185,7 +184,7 @@ def write( if ( auto_scroll and not self.is_vertical_scrollbar_grabbed - and self.is_vertical_scroll_end + and is_vertical_scroll_end ): self.scroll_end(animate=False, immediate=True, x_axis=False) return self diff --git a/src/textual/widgets/_sparkline.py b/src/textual/widgets/_sparkline.py index 13e302a361..9fac8f0776 100644 --- a/src/textual/widgets/_sparkline.py +++ b/src/textual/widgets/_sparkline.py @@ -3,6 +3,7 @@ from typing import Callable, ClassVar, Optional, Sequence from textual.app import RenderResult +from textual.color import Color from textual.reactive import reactive from textual.renderables.sparkline import Sparkline as SparklineRenderable from textual.widget import Widget @@ -56,6 +57,8 @@ def __init__( self, data: Sequence[float] | None = None, *, + min_color: Color | str | None = None, + max_color: Color | str | None = None, summary_function: Callable[[Sequence[float]], float] | None = None, name: str | None = None, id: str | None = None, @@ -66,6 +69,8 @@ def __init__( Args: data: The initial data to populate the sparkline with. + min_color: The color of the minimum value, or `None` to take from CSS. + max_color: the color of the maximum value, or `None` to take from CSS. summary_function: Summarizes bar values into a single value used to represent each bar. name: The name of the widget. @@ -74,6 +79,8 @@ def __init__( disabled: Whether the widget is disabled or not. """ super().__init__(name=name, id=id, classes=classes, disabled=disabled) + self.min_color = None if min_color is None else Color.parse(min_color) + self.max_color = None if max_color is None else Color.parse(max_color) self.data = data if summary_function is not None: self.summary_function = summary_function @@ -83,14 +90,20 @@ def render(self) -> RenderResult: if not self.data: return "" _, base = self.background_colors + min_color = ( + self.get_component_styles("sparkline--min-color").color + if self.min_color is None + else self.min_color + ) + max_color = ( + self.get_component_styles("sparkline--max-color").color + if self.min_color is None + else self.max_color + ) return SparklineRenderable( self.data, width=self.size.width, - min_color=( - base + self.get_component_styles("sparkline--min-color").color - ).rich_color, - max_color=( - base + self.get_component_styles("sparkline--max-color").color - ).rich_color, + min_color=min_color.rich_color, + max_color=max_color.rich_color, summary_function=self.summary_function, ) From 2a4a38bd71647cf6a742e4505b97353670c4a193 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 25 Oct 2024 13:24:30 +0100 Subject: [PATCH 3/9] replace demo --- src/textual/__main__.py | 2 +- src/textual/demo.py | 400 ------------------------ src/textual/demo.tcss | 271 ---------------- src/textual/{demo2 => demo}/__main__.py | 0 src/textual/{demo2 => demo}/data.py | 0 src/textual/{demo2 => demo}/demo_app.py | 12 +- src/textual/{demo2 => demo}/home.py | 12 +- src/textual/{demo2 => demo}/page.py | 0 src/textual/{demo2 => demo}/projects.py | 2 +- src/textual/{demo2 => demo}/widgets.py | 35 ++- 10 files changed, 46 insertions(+), 688 deletions(-) delete mode 100644 src/textual/demo.py delete mode 100644 src/textual/demo.tcss rename src/textual/{demo2 => demo}/__main__.py (100%) rename src/textual/{demo2 => demo}/data.py (100%) rename src/textual/{demo2 => demo}/demo_app.py (74%) rename src/textual/{demo2 => demo}/home.py (96%) rename src/textual/{demo2 => demo}/page.py (100%) rename src/textual/{demo2 => demo}/projects.py (99%) rename src/textual/{demo2 => demo}/widgets.py (93%) diff --git a/src/textual/__main__.py b/src/textual/__main__.py index 3bf8ea235c..9da832d968 100644 --- a/src/textual/__main__.py +++ b/src/textual/__main__.py @@ -1,4 +1,4 @@ -from textual.demo import DemoApp +from textual.demo.demo_app import DemoApp if __name__ == "__main__": app = DemoApp() diff --git a/src/textual/demo.py b/src/textual/demo.py deleted file mode 100644 index 9a5acf176d..0000000000 --- a/src/textual/demo.py +++ /dev/null @@ -1,400 +0,0 @@ -from __future__ import annotations - -from importlib.metadata import version -from pathlib import Path -from typing import cast - -from rich import box -from rich.console import RenderableType -from rich.json import JSON -from rich.markdown import Markdown -from rich.markup import escape -from rich.pretty import Pretty -from rich.syntax import Syntax -from rich.table import Table -from rich.text import Text - -from textual.app import App, ComposeResult -from textual.binding import Binding -from textual.containers import Container, Horizontal, ScrollableContainer -from textual.reactive import reactive -from textual.widgets import ( - Button, - DataTable, - Footer, - Header, - Input, - RichLog, - Static, - Switch, -) - -from_markup = Text.from_markup - -example_table = Table( - show_edge=False, - show_header=True, - expand=True, - row_styles=["none", "dim"], - box=box.SIMPLE, -) -example_table.add_column(from_markup("[green]Date"), style="green", no_wrap=True) -example_table.add_column(from_markup("[blue]Title"), style="blue") - -example_table.add_column( - from_markup("[magenta]Box Office"), - style="magenta", - justify="right", - no_wrap=True, -) -example_table.add_row( - "Dec 20, 2019", - "Star Wars: The Rise of Skywalker", - "$375,126,118", -) -example_table.add_row( - "May 25, 2018", - from_markup("[b]Solo[/]: A Star Wars Story"), - "$393,151,347", -) -example_table.add_row( - "Dec 15, 2017", - "Star Wars Ep. VIII: The Last Jedi", - from_markup("[bold]$1,332,539,889[/bold]"), -) -example_table.add_row( - "May 19, 1999", - from_markup("Star Wars Ep. [b]I[/b]: [i]The phantom Menace"), - "$1,027,044,677", -) - - -WELCOME_MD = """ - -## Textual Demo - -**Welcome**! Textual is a framework for creating sophisticated applications with the terminal. -""" - - -RICH_MD = """ - -Textual is built on **Rich**, the popular Python library for advanced terminal output. - -Add content to your Textual App with Rich *renderables* (this text is written in Markdown and formatted with Rich's Markdown class). - -Here are some examples: -""" - -CSS_MD = """ - -Textual uses Cascading Stylesheets (CSS) to create Rich interactive User Interfaces. - -- **Easy to learn** - much simpler than browser CSS -- **Live editing** - see your changes without restarting the app! - -Here's an example of some CSS used in this app: -""" - -DATA = { - "foo": [ - 3.1427, - ( - "Paul Atreides", - "Vladimir Harkonnen", - "Thufir Hawat", - "Gurney Halleck", - "Duncan Idaho", - ), - ], -} - -WIDGETS_MD = """ - -Textual widgets are powerful interactive components. - -Build your own or use the builtin widgets. - -- **Input** Text / Password input. -- **Button** Clickable button with a number of styles. -- **Switch** A switch to toggle between states. -- **DataTable** A spreadsheet-like widget for navigating data. Cells may contain text or Rich renderables. -- **Tree** An generic tree with expandable nodes. -- **DirectoryTree** A tree of file and folders. -- *... many more planned ...* -""" - - -MESSAGE = """ -We hope you enjoy using Textual. - -Here are some links. You can click these! - -[@click="app.open_link('https://textual.textualize.io')"]Textual Docs[/] - -[@click="app.open_link('https://github.com/Textualize/textual')"]Textual GitHub Repository[/] - -[@click="app.open_link('https://github.com/Textualize/rich')"]Rich GitHub Repository[/] - - -Built with ♥ by [@click="app.open_link('https://www.textualize.io')"]Textualize.io[/] -""" - - -JSON_EXAMPLE = """{ - "glossary": { - "title": "example glossary", - "GlossDiv": { - "title": "S", - "GlossList": { - "GlossEntry": { - "ID": "SGML", - "SortAs": "SGML", - "GlossTerm": "Standard Generalized Markup Language", - "Acronym": "SGML", - "Abbrev": "ISO 8879:1986", - "GlossDef": { - "para": "A meta-markup language, used to create markup languages such as DocBook.", - "GlossSeeAlso": ["GML", "XML"] - }, - "GlossSee": "markup" - } - } - } - } -} -""" - - -class Body(ScrollableContainer): - pass - - -class Title(Static): - pass - - -class DarkSwitch(Horizontal): - def compose(self) -> ComposeResult: - yield Switch(value=self.app.dark) - yield Static("Dark mode toggle", classes="label") - - def on_mount(self) -> None: - self.watch(self.app, "dark", self.on_dark_change, init=False) - - def on_dark_change(self) -> None: - self.query_one(Switch).value = self.app.dark - - def on_switch_changed(self, event: Switch.Changed) -> None: - self.app.dark = event.value - - -class Welcome(Container): - ALLOW_MAXIMIZE = True - - def compose(self) -> ComposeResult: - yield Static(Markdown(WELCOME_MD)) - yield Button("Start", variant="success") - - def on_button_pressed(self, event: Button.Pressed) -> None: - app = cast(DemoApp, self.app) - app.add_note("[b magenta]Start!") - app.query_one(".location-first").scroll_visible(duration=0.5, top=True) - - -class OptionGroup(Container): - pass - - -class SectionTitle(Static): - pass - - -class Message(Static): - pass - - -class Version(Static): - def render(self) -> RenderableType: - return f"[b]v{version('textual')}" - - -class Sidebar(Container): - def compose(self) -> ComposeResult: - yield Title("Textual Demo") - yield OptionGroup(Message(MESSAGE), Version()) - yield DarkSwitch() - - -class AboveFold(Container): - pass - - -class Section(Container): - pass - - -class Column(Container): - pass - - -class TextContent(Static): - pass - - -class QuickAccess(Container): - pass - - -class LocationLink(Static): - def __init__(self, label: str, reveal: str) -> None: - super().__init__(label) - self.reveal = reveal - - def on_click(self) -> None: - app = cast(DemoApp, self.app) - app.query_one(self.reveal).scroll_visible(top=True, duration=0.5) - app.add_note(f"Scrolling to [b]{self.reveal}[/b]") - - -class LoginForm(Container): - ALLOW_MAXIMIZE = True - - def compose(self) -> ComposeResult: - yield Static("Username", classes="label") - yield Input(placeholder="Username") - yield Static("Password", classes="label") - yield Input(placeholder="Password", password=True) - yield Static() - yield Button("Login", variant="primary") - - -class Window(Container): - pass - - -class SubTitle(Static): - pass - - -class DemoApp(App[None]): - CSS_PATH = "demo.tcss" - TITLE = "Textual Demo" - BINDINGS = [ - ("ctrl+b", "toggle_sidebar", "Sidebar"), - ("ctrl+t", "app.toggle_dark", "Toggle Dark mode"), - ("ctrl+s", "app.screenshot()", "Screenshot"), - ("f1", "app.toggle_class('RichLog', '-hidden')", "Notes"), - Binding("ctrl+q", "app.quit", "Quit", show=True), - ] - - show_sidebar = reactive(False) - - def add_note(self, renderable: RenderableType) -> None: - self.query_one(RichLog).write(renderable) - - def compose(self) -> ComposeResult: - example_css = Path(self.css_path[0]).read_text() - yield Container( - Sidebar(classes="-hidden"), - Header(show_clock=False), - RichLog(classes="-hidden", wrap=False, highlight=True, markup=True), - Body( - QuickAccess( - LocationLink("TOP", ".location-top"), - LocationLink("Widgets", ".location-widgets"), - LocationLink("Rich content", ".location-rich"), - LocationLink("CSS", ".location-css"), - ), - AboveFold(Welcome(), classes="location-top"), - Column( - Section( - SectionTitle("Widgets"), - TextContent(Markdown(WIDGETS_MD)), - LoginForm(), - DataTable(), - ), - classes="location-widgets location-first", - ), - Column( - Section( - SectionTitle("Rich"), - TextContent(Markdown(RICH_MD)), - SubTitle("Pretty Printed data (try resizing the terminal)"), - Static(Pretty(DATA, indent_guides=True), classes="pretty pad"), - SubTitle("JSON"), - Window(Static(JSON(JSON_EXAMPLE), expand=True), classes="pad"), - SubTitle("Tables"), - Static(example_table, classes="table pad"), - ), - classes="location-rich", - ), - Column( - Section( - SectionTitle("CSS"), - TextContent(Markdown(CSS_MD)), - Window( - Static( - Syntax( - example_css, - "css", - theme="material", - line_numbers=True, - ), - expand=True, - ) - ), - ), - classes="location-css", - ), - ), - ) - yield Footer() - - def action_open_link(self, link: str) -> None: - self.app.bell() - import webbrowser - - webbrowser.open(link) - - def action_toggle_sidebar(self) -> None: - sidebar = self.query_one(Sidebar) - self.set_focus(None) - if sidebar.has_class("-hidden"): - sidebar.remove_class("-hidden") - else: - if sidebar.query("*:focus"): - self.screen.set_focus(None) - sidebar.add_class("-hidden") - - def on_mount(self) -> None: - self.add_note("Textual Demo app is running") - table = self.query_one(DataTable) - table.add_column("Foo", width=20) - table.add_column("Bar", width=20) - table.add_column("Baz", width=20) - table.add_column("Foo", width=20) - table.add_column("Bar", width=20) - table.add_column("Baz", width=20) - table.zebra_stripes = True - for n in range(20): - table.add_row(*[f"Cell ([b]{n}[/b], {col})" for col in range(6)]) - self.query_one("Welcome Button", Button).focus() - - def action_screenshot(self, filename: str | None = None, path: str = "./") -> None: - """Save an SVG "screenshot". This action will save an SVG file containing the current contents of the screen. - - Args: - filename: Filename of screenshot, or None to auto-generate. - path: Path to directory. - """ - self.bell() - path = self.save_screenshot(filename, path) - message = f"Screenshot saved to [bold green]'{escape(str(path))}'[/]" - self.add_note(Text.from_markup(message)) - self.notify(message) - - -app = DemoApp() -if __name__ == "__main__": - app.run() diff --git a/src/textual/demo.tcss b/src/textual/demo.tcss deleted file mode 100644 index 4febd734be..0000000000 --- a/src/textual/demo.tcss +++ /dev/null @@ -1,271 +0,0 @@ -* { - transition: background 500ms in_out_cubic, color 500ms in_out_cubic; -} - -Screen { - layers: base overlay notes notifications; - overflow: hidden; - &:inline { - height: 50vh; - } - &.-maximized-view { - overflow: auto; - } -} - - -Notification { - dock: bottom; - layer: notification; - width: auto; - margin: 2 4; - padding: 1 2; - background: $background; - color: $text; - height: auto; - -} - -Sidebar { - width: 40; - background: $panel; - transition: offset 500ms in_out_cubic; - layer: overlay; - -} - -Sidebar:focus-within { - offset: 0 0 !important; -} - -Sidebar.-hidden { - offset-x: -100%; -} - -Sidebar Title { - background: $boost; - color: $secondary; - padding: 2 4; - border-right: vkey $background; - dock: top; - text-align: center; - text-style: bold; -} - - -OptionGroup { - background: $boost; - color: $text; - height: 1fr; - border-right: vkey $background; -} - -Option { - margin: 1 0 0 1; - height: 3; - padding: 1 2; - background: $boost; - border: tall $panel; - text-align: center; -} - -Option:hover { - background: $primary 20%; - color: $text; -} - -Body { - height: 100%; - overflow-y: scroll; - width: 100%; - background: $surface; - -} - -AboveFold { - width: 100%; - height: 100%; - align: center middle; -} - -Welcome { - background: $boost; - height: auto; - max-width: 100; - min-width: 40; - border: wide $primary; - padding: 1 2; - margin: 1 2; - box-sizing: border-box; -} - -Welcome Button { - width: 100%; - margin-top: 1; -} - -Column { - height: auto; - min-height: 100vh; - align: center top; - overflow: hidden; -} - - -DarkSwitch { - background: $panel; - padding: 1; - dock: bottom; - height: auto; - border-right: vkey $background; -} - -DarkSwitch .label { - width: 1fr; - padding: 1 2; - color: $text-muted; -} - -DarkSwitch Switch { - background: $boost; - dock: left; -} - - -Screen>Container { - height: 100%; - overflow: hidden; -} - -RichLog { - background: $surface; - color: $text; - height: 50vh; - dock: bottom; - layer: notes; - border-top: hkey $primary; - offset-y: 0; - transition: offset 400ms in_out_cubic; - padding: 0 1 1 1; -} - - -RichLog:focus { - offset: 0 0 !important; -} - -RichLog.-hidden { - offset-y: 100%; -} - - - -Section { - height: auto; - min-width: 40; - margin: 1 2 4 2; - -} - -SectionTitle { - padding: 1 2; - background: $boost; - text-align: center; - text-style: bold; -} - -SubTitle { - padding-top: 1; - border-bottom: heavy $panel; - color: $text; - text-style: bold; -} - -TextContent { - margin: 1 0; -} - -QuickAccess { - width: 30; - dock: left; - -} - -LocationLink { - margin: 1 0 0 1; - height: 1; - padding: 1 2; - background: $boost; - color: $text; - box-sizing: content-box; - content-align: center middle; -} - -LocationLink:hover { - background: $accent; - color: $text; - text-style: bold; -} - - -.pad { - margin: 1 0; -} - -DataTable { - height: 16; - max-height: 16; -} - - -LoginForm { - height: auto; - margin: 1 0; - padding: 1 2; - layout: grid; - grid-size: 2; - grid-rows: 4; - grid-columns: 12 1fr; - background: $boost; - border: wide $background; -} - -LoginForm Button { - margin: 0 1; - width: 100%; -} - -LoginForm .label { - padding: 1 2; - text-align: right; -} - -Message { - margin: 0 1; - -} - - -Tree { - margin: 1 0; -} - - -Window { - background: $boost; - overflow: auto; - height: auto; - max-height: 16; -} - -Window>Static { - width: auto; -} - - -Version { - color: $text-disabled; - dock: bottom; - text-align: center; - padding: 1; -} diff --git a/src/textual/demo2/__main__.py b/src/textual/demo/__main__.py similarity index 100% rename from src/textual/demo2/__main__.py rename to src/textual/demo/__main__.py diff --git a/src/textual/demo2/data.py b/src/textual/demo/data.py similarity index 100% rename from src/textual/demo2/data.py rename to src/textual/demo/data.py diff --git a/src/textual/demo2/demo_app.py b/src/textual/demo/demo_app.py similarity index 74% rename from src/textual/demo2/demo_app.py rename to src/textual/demo/demo_app.py index 28307d30b1..d3be0a33c2 100644 --- a/src/textual/demo2/demo_app.py +++ b/src/textual/demo/demo_app.py @@ -1,8 +1,8 @@ from textual.app import App from textual.binding import Binding -from textual.demo2.home import HomeScreen -from textual.demo2.projects import ProjectsScreen -from textual.demo2.widgets import WidgetsScreen +from textual.demo.home import HomeScreen +from textual.demo.projects import ProjectsScreen +from textual.demo.widgets import WidgetsScreen class DemoApp(App): @@ -40,4 +40,10 @@ class DemoApp(App): "widgets", tooltip="Test the builtin widgets", ), + Binding( + "ctrl+s", + "app.screenshot", + "Screenshot", + tooltip="Save an SVG 'screenshot' of the current screen", + ), ] diff --git a/src/textual/demo2/home.py b/src/textual/demo/home.py similarity index 96% rename from src/textual/demo2/home.py rename to src/textual/demo/home.py index 53611c7aaf..2ce64ddd95 100644 --- a/src/textual/demo2/home.py +++ b/src/textual/demo/home.py @@ -6,7 +6,7 @@ from textual import work from textual.app import ComposeResult from textual.containers import Horizontal, Vertical, VerticalScroll -from textual.demo2.page import PageScreen +from textual.demo.page import PageScreen from textual.reactive import reactive from textual.widgets import Collapsible, Digits, Footer, Label, Markdown @@ -41,14 +41,14 @@ ## Textual interfaces are *snappy* Even the most modern of web apps can leave the user waiting hundreds of milliseconds or more for a response. -Given their low graphical requirements, Textual interfaces can be far more responsive—no waiting required. +Given their low graphical requirements, Textual interfaces can be far more responsive — no waiting required. ## Reward repeated use Use the mouse to explore, but Textual apps are keyboard-centric and reward repeated use. An experience user can operate a Textual app far faster than their web / GUI counterparts. ## Command palette -A builtin command palette with fuzzy searching, puts powerful commands at your fingertips. +A builtin command palette with fuzzy searching puts powerful commands at your fingertips. **Try it:** Press **ctrl+p** now. @@ -57,6 +57,12 @@ API_MD = """\ A modern Python API from the developer of [Rich](https://github.com/Textualize/rich). +```python +# Start building! +import textual + +``` + Well documented, typed, and intuitive. Textual's API is accessible to Python developers of all skill levels. diff --git a/src/textual/demo2/page.py b/src/textual/demo/page.py similarity index 100% rename from src/textual/demo2/page.py rename to src/textual/demo/page.py diff --git a/src/textual/demo2/projects.py b/src/textual/demo/projects.py similarity index 99% rename from src/textual/demo2/projects.py rename to src/textual/demo/projects.py index e5fa217491..d352fbcbd3 100644 --- a/src/textual/demo2/projects.py +++ b/src/textual/demo/projects.py @@ -4,7 +4,7 @@ from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Center, Horizontal, ItemGrid, Vertical, VerticalScroll -from textual.demo2.page import PageScreen +from textual.demo.page import PageScreen from textual.widgets import Footer, Label, Link, Markdown, Static diff --git a/src/textual/demo2/widgets.py b/src/textual/demo/widgets.py similarity index 93% rename from src/textual/demo2/widgets.py rename to src/textual/demo/widgets.py index 597d862059..bad0b6ad71 100644 --- a/src/textual/demo2/widgets.py +++ b/src/textual/demo/widgets.py @@ -8,8 +8,8 @@ from textual import containers from textual.app import ComposeResult -from textual.demo2.data import COUNTRIES -from textual.demo2.page import PageScreen +from textual.demo.data import COUNTRIES +from textual.demo.page import PageScreen from textual.reactive import reactive, var from textual.suggester import SuggestFromList from textual.widgets import ( @@ -96,6 +96,8 @@ def compose(self) -> ComposeResult: class Checkboxes(containers.VerticalGroup): + """Demonstrates Checkboxes.""" + DEFAULT_CLASSES = "column" DEFAULT_CSS = """ Checkboxes { @@ -137,6 +139,8 @@ def compose(self) -> ComposeResult: class Datatables(containers.VerticalGroup): + """Demonstrates DataTables.""" + DEFAULT_CLASSES = "column" DATATABLES_MD = """\ ## Datatables @@ -170,6 +174,8 @@ def on_mount(self) -> None: class Inputs(containers.VerticalGroup): + """Demonstrates Inputs.""" + DEFAULT_CLASSES = "column" INPUTS_MD = """\ ## Inputs and MaskedInputs @@ -223,6 +229,8 @@ def compose(self) -> ComposeResult: class ListViews(containers.VerticalGroup): + """Demonstrates List Views and Option Lists.""" + DEFAULT_CLASSES = "column" LISTS_MD = """\ ## List Views and Option Lists @@ -258,6 +266,8 @@ def compose(self) -> ComposeResult: class Logs(containers.VerticalGroup): + """Demonstrates Logs.""" + DEFAULT_CLASSES = "column" LOGS_MD = """\ ## Logs and Rich Logs @@ -334,6 +344,7 @@ def on_mount(self) -> None: self.set_interval(1, self.update_rich_log) def update_log(self) -> None: + """Update the Log with new content.""" if not self.screen.can_view(self) or not self.screen.is_active: return self.log_count += 1 @@ -343,6 +354,7 @@ def update_log(self) -> None: log.write_line(f"fear[{line_no}] = {line!r}") def update_rich_log(self) -> None: + """Update the Rich Log with content.""" if not self.screen.can_view(self) or not self.screen.is_active: return rich_log = self.query_one(RichLog) @@ -367,6 +379,8 @@ def update_rich_log(self) -> None: class Sparklines(containers.VerticalGroup): + """Demonstrates sparklines.""" + DEFAULT_CLASSES = "column" LOGS_MD = """\ ## Sparklines @@ -380,12 +394,12 @@ class Sparklines(containers.VerticalGroup): Sparkline { width: 1fr; margin: 1; - & #first > .sparkline--min-color { color: $success; } - & #first > .sparkline--max-color { color: $warning; } - & #second > .sparkline--min-color { color: $warning; } - & #second > .sparkline--max-color { color: $error; } - & #third > .sparkline--min-color { color: $primary; } - & #third > .sparkline--max-color { color: $accent; } + &#first > .sparkline--min-color { color: $success; } + &#first > .sparkline--max-color { color: $warning; } + &#second > .sparkline--min-color { color: $warning; } + &#second > .sparkline--max-color { color: $error; } + &#third > .sparkline--min-color { color: $primary; } + &#third > .sparkline--max-color { color: $accent; } } } @@ -407,9 +421,10 @@ def compose(self) -> ComposeResult: ) def on_mount(self) -> None: - self.set_interval(0.1, self.update_sparks) + self.set_interval(0.2, self.update_sparks) def update_sparks(self) -> None: + """Update the sparks data.""" if not self.screen.can_view(self) or not self.screen.is_active: return self.count += 1 @@ -418,6 +433,8 @@ def update_sparks(self) -> None: class WidgetsScreen(PageScreen): + """The Widgets screen""" + CSS = """ WidgetsScreen { align-horizontal: center; From d12c207ebebb0a73450720feafadc92bf7a8536c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 25 Oct 2024 13:29:20 +0100 Subject: [PATCH 4/9] changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0532cd9139..db4fb32bcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `Button.action` parameter to invoke action when clicked https://github.com/Textualize/textual/pull/5113 - Added `immediate` parameter to scroll methods https://github.com/Textualize/textual/pull/5164 - Added `textual._loop.loop_from_index` https://github.com/Textualize/textual/pull/5164 -- Added `min_color` and `max_color` to Sparklines constructor, which take precedence over CSS +- Added `min_color` and `max_color` to Sparklines constructor, which take precedence over CSS https://github.com/Textualize/textual/pull/5174 +- Added new demo `python -m textual`, not *quite* finished but better than the old one https://github.com/Textualize/textual/pull/5174 ### Fixed From c10ebca840eb88245120b992f636a1710ada77a7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 25 Oct 2024 13:36:49 +0100 Subject: [PATCH 5/9] ws --- src/textual/demo/home.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/demo/home.py b/src/textual/demo/home.py index 2ce64ddd95..06a76b6aa5 100644 --- a/src/textual/demo/home.py +++ b/src/textual/demo/home.py @@ -60,7 +60,6 @@ ```python # Start building! import textual - ``` Well documented, typed, and intuitive. From f5d6c81a018c89fa0c865ca4d0ed0044370d8a73 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 25 Oct 2024 13:45:03 +0100 Subject: [PATCH 6/9] sparkline fix --- src/textual/widgets/_sparkline.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_sparkline.py b/src/textual/widgets/_sparkline.py index 9fac8f0776..5eb284b591 100644 --- a/src/textual/widgets/_sparkline.py +++ b/src/textual/widgets/_sparkline.py @@ -90,14 +90,14 @@ def render(self) -> RenderResult: if not self.data: return "" _, base = self.background_colors - min_color = ( + min_color = base + ( self.get_component_styles("sparkline--min-color").color if self.min_color is None else self.min_color ) - max_color = ( + max_color = base + ( self.get_component_styles("sparkline--max-color").color - if self.min_color is None + if self.max_color is None else self.max_color ) return SparklineRenderable( From 369a83290f79057a787d4279d40bbc3c4f41e749 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 25 Oct 2024 14:31:56 +0100 Subject: [PATCH 7/9] test fixes --- src/textual/demo/widgets.py | 8 ++++---- src/textual/widget.py | 4 ++-- src/textual/widgets/_rich_log.py | 2 +- tests/snapshot_tests/test_snapshots.py | 8 -------- 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/textual/demo/widgets.py b/src/textual/demo/widgets.py index bad0b6ad71..edb3e1e6ca 100644 --- a/src/textual/demo/widgets.py +++ b/src/textual/demo/widgets.py @@ -361,16 +361,16 @@ def update_rich_log(self) -> None: self.rich_log_count += 1 log_option = self.rich_log_count % 3 if log_option == 0: - rich_log.write("Syntax highlighted code") + rich_log.write("Syntax highlighted code", animate=True) rich_log.write(Syntax(self.CODE, lexer="python"), animate=True) elif log_option == 1: - rich_log.write("A Rich Table") + rich_log.write("A Rich Table", animate=True) table = Table(*self.CSV_ROWS[0]) for row in self.CSV_ROWS[1:]: table.add_row(*row) rich_log.write(table, animate=True) elif log_option == 2: - rich_log.write("A Rich Traceback") + rich_log.write("A Rich Traceback", animate=True) try: 1 / 0 except Exception: @@ -414,7 +414,7 @@ def compose(self) -> ComposeResult: Sparklines.data, ) yield Sparkline([], summary_function=max, id="second").data_bind( - Sparklines.data + Sparklines.data, ) yield Sparkline([], summary_function=max, id="third").data_bind( Sparklines.data, diff --git a/src/textual/widget.py b/src/textual/widget.py index 1fe04aedf8..53392a69bc 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1647,12 +1647,12 @@ def max_scroll_y(self) -> int: @property def is_vertical_scroll_end(self) -> bool: """Is the vertical scroll position at the maximum?""" - return self.scroll_offset.y == self.max_scroll_y + return self.scroll_offset.y == self.max_scroll_y or not self.size @property def is_horizontal_scroll_end(self) -> bool: """Is the horizontal scroll position at the maximum?""" - return self.scroll_offset.x == self.max_scroll_x + return self.scroll_offset.x == self.max_scroll_x or not self.size @property def is_vertical_scrollbar_grabbed(self) -> bool: diff --git a/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index a974503058..f0585c9dfe 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -274,7 +274,7 @@ def write( and not self.is_vertical_scrollbar_grabbed and is_vertical_scroll_end ): - self.scroll_end(animate=animate, immediate=True, x_axis=False) + self.scroll_end(animate=animate, immediate=False, x_axis=False) return self diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index f1a9de43ff..0fa1678090 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -599,14 +599,6 @@ def test_key_display(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "key_display.py") -def test_demo(snap_compare): - """Test the demo app (python -m textual)""" - assert snap_compare( - Path("../../src/textual/demo.py"), - terminal_size=(100, 30), - ) - - def test_label_widths(snap_compare): """Test renderable widths are calculate correctly.""" assert snap_compare(SNAPSHOT_APPS_DIR / "label_widths.py") From e76e42921f1f265b506f52431bb926f35083ea27 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 25 Oct 2024 15:07:34 +0100 Subject: [PATCH 8/9] can view fixes --- CHANGELOG.md | 2 ++ src/textual/demo/widgets.py | 12 ++++++------ src/textual/screen.py | 32 ++++++++++++++++++++++++++------ src/textual/widget.py | 33 ++++++++++++++++++++++++++++++--- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db4fb32bcc..f1583c243b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Grid will now size children to the maximum height of a row https://github.com/Textualize/textual/pull/5113 - Markdown links will be opened with `App.open_url` automatically https://github.com/Textualize/textual/pull/5113 - The universal selector (`*`) will now not match widgets with the class `-textual-system` (scrollbars, notifications etc) https://github.com/Textualize/textual/pull/5113 +- Renamed `Screen.can_view` and `Widget.can_view` to `Screen.can_view_entire` and `Widget.can_view_entire` https://github.com/Textualize/textual/pull/5174 ### Added @@ -26,6 +27,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `textual._loop.loop_from_index` https://github.com/Textualize/textual/pull/5164 - Added `min_color` and `max_color` to Sparklines constructor, which take precedence over CSS https://github.com/Textualize/textual/pull/5174 - Added new demo `python -m textual`, not *quite* finished but better than the old one https://github.com/Textualize/textual/pull/5174 +- Added `Screen.can_view_partial` and `Widget.can_view_partial` https://github.com/Textualize/textual/pull/5174 ### Fixed diff --git a/src/textual/demo/widgets.py b/src/textual/demo/widgets.py index edb3e1e6ca..7432dbdde8 100644 --- a/src/textual/demo/widgets.py +++ b/src/textual/demo/widgets.py @@ -339,25 +339,25 @@ def on_mount(self) -> None: log = self.query_one(Log) rich_log = self.query_one(RichLog) log.write("I am a Log Widget") - rich_log.write("I am a [b]Rich Log Widget") + rich_log.write("I am a Rich Log Widget") self.set_interval(0.25, self.update_log) self.set_interval(1, self.update_rich_log) def update_log(self) -> None: """Update the Log with new content.""" - if not self.screen.can_view(self) or not self.screen.is_active: + log = self.query_one(Log) + if not self.screen.can_view_partial(log) or not self.screen.is_active: return self.log_count += 1 - log = self.query_one(Log) line_no = self.log_count % len(self.TEXT) line = self.TEXT[self.log_count % len(self.TEXT)] log.write_line(f"fear[{line_no}] = {line!r}") def update_rich_log(self) -> None: """Update the Rich Log with content.""" - if not self.screen.can_view(self) or not self.screen.is_active: - return rich_log = self.query_one(RichLog) + if not self.screen.can_view_partial(rich_log) or not self.screen.is_active: + return self.rich_log_count += 1 log_option = self.rich_log_count % 3 if log_option == 0: @@ -425,7 +425,7 @@ def on_mount(self) -> None: def update_sparks(self) -> None: """Update the sparks data.""" - if not self.screen.can_view(self) or not self.screen.is_active: + if not self.screen.can_view_partial(self) or not self.screen.is_active: return self.count += 1 offset = self.count * 40 diff --git a/src/textual/screen.py b/src/textual/screen.py index f60cad4aa9..ab9b187efa 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -896,7 +896,7 @@ def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None: def scroll_to_center(widget: Widget) -> None: """Scroll to center (after a refresh).""" - if self.focused is widget and not self.can_view(widget): + if self.focused is widget and not self.can_view_entire(widget): self.scroll_to_center(widget, origin_visible=True) self.call_later(scroll_to_center, widget) @@ -1480,24 +1480,44 @@ async def action_dismiss(self, result: ScreenResultType | None = None) -> None: await self._flush_next_callbacks() self.dismiss(result) - def can_view(self, widget: Widget) -> bool: - """Check if a given widget is in the current view (scrollable area). + def can_view_entire(self, widget: Widget) -> bool: + """Check if a given widget is fully within the current screen. Note: This doesn't necessarily equate to a widget being visible. There are other reasons why a widget may not be visible. Args: - widget: A widget that is a descendant of self. + widget: A widget. Returns: - True if the entire widget is in view, False if it is partially visible or not in view. + `True` if the entire widget is in view, `False` if it is partially visible or not in view. """ + if widget not in self._compositor.visible_widgets: + return False + # If the widget is one that overlays the screen... + if widget.styles.overlay == "screen": + # ...simply check if it's within the screen's region. + return widget.region in self.region + # Failing that fall back to normal checking. + return super().can_view_entire(widget) + + def can_view_partial(self, widget: Widget) -> bool: + """Check if a given widget is at least partially within the current view. + + Args: + widget: A widget. + + Returns: + `True` if the any part of the widget is in view, `False` if it is completely outside of the screen. + """ + if widget not in self._compositor.visible_widgets: + return False # If the widget is one that overlays the screen... if widget.styles.overlay == "screen": # ...simply check if it's within the screen's region. return widget.region in self.region # Failing that fall back to normal checking. - return super().can_view(widget) + return super().can_view_partial(widget) def validate_title(self, title: Any) -> str | None: """Ensure the title is a string or `None`.""" diff --git a/src/textual/widget.py b/src/textual/widget.py index 53392a69bc..7c71bb885c 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -3303,8 +3303,8 @@ def scroll_to_center( immediate=immediate, ) - def can_view(self, widget: Widget) -> bool: - """Check if a given widget is in the current view (scrollable area). + def can_view_entire(self, widget: Widget) -> bool: + """Check if a given widget is *fully* within the current view (scrollable area). Note: This doesn't necessarily equate to a widget being visible. There are other reasons why a widget may not be visible. @@ -3313,11 +3313,38 @@ def can_view(self, widget: Widget) -> bool: widget: A widget that is a descendant of self. Returns: - True if the entire widget is in view, False if it is partially visible or not in view. + `True` if the entire widget is in view, `False` if it is partially visible or not in view. """ if widget is self: return True + if widget not in self.screen._compositor.visible_widgets: + return False + + region = widget.region + node: Widget = widget + + while isinstance(node.parent, Widget) and node is not self: + if region not in node.parent.scrollable_content_region: + return False + node = node.parent + return True + + def can_view_partial(self, widget: Widget) -> bool: + """Check if a given widget at least partially visible within the current view (scrollable area). + + Args: + widget: A widget that is a descendant of self. + + Returns: + `True` if any part of the widget is visible, `False` if it is outside of the viewable area. + """ + if widget is self: + return True + + if widget not in self.screen._compositor.visible_widgets or not widget.display: + return False + region = widget.region node: Widget = widget From ff336342bc98259ff2512fa2c3754281e7259515 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 25 Oct 2024 15:22:13 +0100 Subject: [PATCH 9/9] removed old snapshot --- .../test_snapshots/test_demo.svg | 189 ------------------ 1 file changed, 189 deletions(-) delete mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_demo.svg diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_demo.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_demo.svg deleted file mode 100644 index 44fe56e48f..0000000000 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_demo.svg +++ /dev/null @@ -1,189 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Textual Demo - - - - - - - - - - Textual Demo - - -TOP - -▆▆ - -Widgets -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - -Rich contentTextual Demo - -Welcome! Textual is a framework for creating sophisticated -applications with the terminal.                            -CSS -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Start  -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - - - - - - - - ^b Sidebar  ^t Toggle Dark mode  ^s Screenshot  f1 Notes  ^q Quit ^p palette - - -