diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b365302b..91baaca01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added Widget._refresh_scroll to avoid expensive layout when scrolling https://github.com/Textualize/textual/pull/1524 - `events.Paste` now bubbles https://github.com/Textualize/textual/issues/1434 - Clock color in the `Header` widget now matches the header color https://github.com/Textualize/textual/issues/1459 +- Programmatic calls to scroll now optionally scroll even if overflow styling says otherwise (introduces a new `force` parameter to all the `scroll_*` methods) https://github.com/Textualize/textual/issues/1201 - `COMPONENT_CLASSES` are now inherited from base classes https://github.com/Textualize/textual/issues/1399 - Watch methods may now take no parameters - Added `compute` parameter to reactive @@ -36,6 +37,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Behavior of widget `Input` when rendering after programmatic value change and related scenarios https://github.com/Textualize/textual/issues/1477 https://github.com/Textualize/textual/issues/1443 - `DataTable.show_cursor` now correctly allows cursor toggling https://github.com/Textualize/textual/pull/1547 - Fixed cursor not being visible on `DataTable` mount when `fixed_columns` were used https://github.com/Textualize/textual/pull/1547 +- Fixed TextLog wrapping issue https://github.com/Textualize/textual/issues/1554 +- Fixed issue with TextLog not writing anything before layout https://github.com/Textualize/textual/issues/1498 ## [0.9.1] - 2022-12-30 diff --git a/examples/README.md b/examples/README.md index 0463bd2eb..4b412e51c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,7 +2,7 @@ This directory contains example Textual applications. -To run them, navigate to the examples directory and enter `python` followed buy the name of the Python file. +To run them, navigate to the examples directory and enter `python` followed by the name of the Python file. ``` cd textual/examples diff --git a/src/textual/app.py b/src/textual/app.py index 6b99ac48f..5e574f73a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -240,8 +240,20 @@ class App(Generic[ReturnType], DOMNode): SCREENS: dict[str, Screen | Callable[[], Screen]] = {} _BASE_PATH: str | None = None CSS_PATH: CSSPathType = None + TITLE: str | None = None + """str | None: The default title for the application. + + If set to a string, this sets the default title for the application. See + also the `title` attribute. + """ + SUB_TITLE: str | None = None + """str | None: The default sub-title for the application. + + If set to a string, this sets the default sub-title for the application. See + also the `sub_title` attribute. + """ BINDINGS = [ Binding("ctrl+c", "quit", "Quit", show=False, priority=True), @@ -303,10 +315,24 @@ class App(Generic[ReturnType], DOMNode): self._animator = Animator(self) self._animate = self._animator.bind(self) self.mouse_position = Offset(0, 0) + self.title = ( self.TITLE if self.TITLE is not None else f"{self.__class__.__name__}" ) + """The title for the application. + + The initial value in a running application will be that set in `TITLE` + (if one is set). Assign new values to this instance attribute to change + the title. + """ + self.sub_title = self.SUB_TITLE if self.SUB_TITLE is not None else "" + """The sub-title for the application. + + The initial value in a running application will be that set in `SUB_TITLE` + (if one is set). Assign new values to this instance attribute to change + the sub-title. + """ self._logger = Logger(self._log) diff --git a/src/textual/binding.py b/src/textual/binding.py index c8e145977..083b1e180 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -28,17 +28,17 @@ class Binding: """The configuration of a key binding.""" key: str - """str: Key to bind. This can also be a comma-separated list of keys to map multiple keys to a single action.""" + """Key to bind. This can also be a comma-separated list of keys to map multiple keys to a single action.""" action: str - """str: Action to bind to.""" + """Action to bind to.""" description: str - """str: Description of action.""" + """Description of action.""" show: bool = True - """bool: Show the action in Footer, or False to hide.""" + """Show the action in Footer, or False to hide.""" key_display: str | None = None - """str | None: How the key should be shown in footer.""" + """How the key should be shown in footer.""" priority: bool = False - """bool: Enable priority binding for this key.""" + """Enable priority binding for this key.""" @rich.repr.auto diff --git a/src/textual/css/match.py b/src/textual/css/match.py index 35a60ea4a..187b5d811 100644 --- a/src/textual/css/match.py +++ b/src/textual/css/match.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: def match(selector_sets: Iterable[SelectorSet], node: DOMNode) -> bool: - """Check if a given selector matches any of the given selector sets. + """Check if a given node matches any of the given selector sets. Args: selector_sets (Iterable[SelectorSet]): Iterable of selector sets. diff --git a/src/textual/strip.py b/src/textual/strip.py index 9f8575c6d..3af759d4f 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -57,12 +57,14 @@ class Strip: return cls([Segment(" " * cell_length, style)], cell_length) @classmethod - def from_lines(cls, lines: list[list[Segment]], cell_length: int) -> list[Strip]: + def from_lines( + cls, lines: list[list[Segment]], cell_length: int | None = None + ) -> list[Strip]: """Convert lines (lists of segments) to a list of Strips. Args: lines (list[list[Segment]]): List of lines, where a line is a list of segments. - cell_length (int): Cell length of lines (must be same). + cell_length (int | None): Cell length of lines (must be same) or None if not known. Defaults to None. Returns: list[Strip]: List of strips. diff --git a/src/textual/widget.py b/src/textual/widget.py index 37c6bcc25..9cd842261 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -803,16 +803,14 @@ class Widget(DOMNode): self.highlight_link_id = hover_style.link_id def watch_scroll_x(self, old_value: float, new_value: float) -> None: - if self.show_horizontal_scrollbar: - self.horizontal_scrollbar.position = round(new_value) - if round(old_value) != round(new_value): - self._refresh_scroll() + self.horizontal_scrollbar.position = round(new_value) + if round(old_value) != round(new_value): + self._refresh_scroll() def watch_scroll_y(self, old_value: float, new_value: float) -> None: - if self.show_vertical_scrollbar: - self.vertical_scrollbar.position = round(new_value) - if round(old_value) != round(new_value): - self._refresh_scroll() + self.vertical_scrollbar.position = round(new_value) + if round(old_value) != round(new_value): + self._refresh_scroll() def validate_scroll_x(self, value: float) -> float: return clamp(value, 0, self.max_scroll_x) @@ -1340,6 +1338,7 @@ class Widget(DOMNode): speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, + force: bool = False, ) -> bool: """Scroll to a given (absolute) coordinate, optionally animating. @@ -1351,10 +1350,13 @@ class Widget(DOMNode): duration (float | None, optional): Duration of animation, if animate is True and speed is None. easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None", which will result in Textual choosing the default scrolling easing function. + force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`. Returns: bool: True if the scroll position changed, otherwise False. """ + maybe_scroll_x = x is not None and (self.allow_horizontal_scroll or force) + maybe_scroll_y = y is not None and (self.allow_vertical_scroll or force) scrolled_x = scrolled_y = False if animate: # TODO: configure animation speed @@ -1364,7 +1366,7 @@ class Widget(DOMNode): if easing is None: easing = DEFAULT_SCROLL_EASING - if x is not None: + if maybe_scroll_x: self.scroll_target_x = x if x != self.scroll_x: self.animate( @@ -1375,7 +1377,7 @@ class Widget(DOMNode): easing=easing, ) scrolled_x = True - if y is not None: + if maybe_scroll_y: self.scroll_target_y = y if y != self.scroll_y: self.animate( @@ -1388,11 +1390,11 @@ class Widget(DOMNode): scrolled_y = True else: - if x is not None: + if maybe_scroll_x: scroll_x = self.scroll_x self.scroll_target_x = self.scroll_x = x scrolled_x = scroll_x != self.scroll_x - if y is not None: + if maybe_scroll_y: scroll_y = self.scroll_y self.scroll_target_y = self.scroll_y = y scrolled_y = scroll_y != self.scroll_y @@ -1408,6 +1410,7 @@ class Widget(DOMNode): speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, + force: bool = False, ) -> bool: """Scroll relative to current position. @@ -1419,6 +1422,7 @@ class Widget(DOMNode): duration (float | None, optional): Duration of animation, if animate is True and speed is None. easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None", which will result in Textual choosing the configured default scrolling easing function. + force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`. Returns: bool: True if the scroll position changed, otherwise False. @@ -1430,6 +1434,7 @@ class Widget(DOMNode): speed=speed, duration=duration, easing=easing, + force=force, ) def scroll_home( @@ -1439,6 +1444,7 @@ class Widget(DOMNode): speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, + force: bool = False, ) -> bool: """Scroll to home position. @@ -1448,6 +1454,7 @@ class Widget(DOMNode): duration (float | None, optional): Duration of animation, if animate is True and speed is None. easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None", which will result in Textual choosing the configured default scrolling easing function. + force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`. Returns: bool: True if any scrolling was done. @@ -1455,7 +1462,13 @@ class Widget(DOMNode): if speed is None and duration is None: duration = 1.0 return self.scroll_to( - 0, 0, animate=animate, speed=speed, duration=duration, easing=easing + 0, + 0, + animate=animate, + speed=speed, + duration=duration, + easing=easing, + force=force, ) def scroll_end( @@ -1465,6 +1478,7 @@ class Widget(DOMNode): speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, + force: bool = False, ) -> bool: """Scroll to the end of the container. @@ -1474,6 +1488,7 @@ class Widget(DOMNode): duration (float | None, optional): Duration of animation, if animate is True and speed is None. easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None", which will result in Textual choosing the configured default scrolling easing function. + force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`. Returns: bool: True if any scrolling was done. @@ -1488,6 +1503,7 @@ class Widget(DOMNode): speed=speed, duration=duration, easing=easing, + force=force, ) def scroll_left( @@ -1497,6 +1513,7 @@ class Widget(DOMNode): speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, + force: bool = False, ) -> bool: """Scroll one cell left. @@ -1506,6 +1523,7 @@ class Widget(DOMNode): duration (float | None, optional): Duration of animation, if animate is True and speed is None. easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None", which will result in Textual choosing the configured default scrolling easing function. + force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`. Returns: bool: True if any scrolling was done. @@ -1517,6 +1535,7 @@ class Widget(DOMNode): speed=speed, duration=duration, easing=easing, + force=force, ) def scroll_right( @@ -1526,6 +1545,7 @@ class Widget(DOMNode): speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, + force: bool = False, ) -> bool: """Scroll on cell right. @@ -1535,6 +1555,7 @@ class Widget(DOMNode): duration (float | None, optional): Duration of animation, if animate is True and speed is None. easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None", which will result in Textual choosing the configured default scrolling easing function. + force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`. Returns: bool: True if any scrolling was done. @@ -1546,6 +1567,7 @@ class Widget(DOMNode): speed=speed, duration=duration, easing=easing, + force=force, ) def scroll_down( @@ -1555,6 +1577,7 @@ class Widget(DOMNode): speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, + force: bool = False, ) -> bool: """Scroll one line down. @@ -1564,6 +1587,7 @@ class Widget(DOMNode): duration (float | None, optional): Duration of animation, if animate is True and speed is None. easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None", which will result in Textual choosing the configured default scrolling easing function. + force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`. Returns: bool: True if any scrolling was done. @@ -1575,6 +1599,7 @@ class Widget(DOMNode): speed=speed, duration=duration, easing=easing, + force=force, ) def scroll_up( @@ -1584,6 +1609,7 @@ class Widget(DOMNode): speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, + force: bool = False, ) -> bool: """Scroll one line up. @@ -1593,6 +1619,7 @@ class Widget(DOMNode): duration (float | None, optional): Duration of animation, if animate is True and speed is None. easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None", which will result in Textual choosing the configured default scrolling easing function. + force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`. Returns: bool: True if any scrolling was done. @@ -1604,6 +1631,7 @@ class Widget(DOMNode): speed=speed, duration=duration, easing=easing, + force=force, ) def scroll_page_up( @@ -1613,6 +1641,7 @@ class Widget(DOMNode): speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, + force: bool = False, ) -> bool: """Scroll one page up. @@ -1622,6 +1651,7 @@ class Widget(DOMNode): duration (float | None, optional): Duration of animation, if animate is True and speed is None. easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None", which will result in Textual choosing the configured default scrolling easing function. + force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`. Returns: bool: True if any scrolling was done. @@ -1633,6 +1663,7 @@ class Widget(DOMNode): speed=speed, duration=duration, easing=easing, + force=force, ) def scroll_page_down( @@ -1642,6 +1673,7 @@ class Widget(DOMNode): speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, + force: bool = False, ) -> bool: """Scroll one page down. @@ -1651,6 +1683,7 @@ class Widget(DOMNode): duration (float | None, optional): Duration of animation, if animate is True and speed is None. easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None", which will result in Textual choosing the configured default scrolling easing function. + force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`. Returns: bool: True if any scrolling was done. @@ -1662,6 +1695,7 @@ class Widget(DOMNode): speed=speed, duration=duration, easing=easing, + force=force, ) def scroll_page_left( @@ -1671,6 +1705,7 @@ class Widget(DOMNode): speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, + force: bool = False, ) -> bool: """Scroll one page left. @@ -1680,6 +1715,7 @@ class Widget(DOMNode): duration (float | None, optional): Duration of animation, if animate is True and speed is None. easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None", which will result in Textual choosing the configured default scrolling easing function. + force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`. Returns: bool: True if any scrolling was done. @@ -1693,6 +1729,7 @@ class Widget(DOMNode): speed=speed, duration=duration, easing=easing, + force=force, ) def scroll_page_right( @@ -1702,6 +1739,7 @@ class Widget(DOMNode): speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, + force: bool = False, ) -> bool: """Scroll one page right. @@ -1711,6 +1749,7 @@ class Widget(DOMNode): duration (float | None, optional): Duration of animation, if animate is True and speed is None. easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None", which will result in Textual choosing the configured default scrolling easing function. + force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`. Returns: bool: True if any scrolling was done. @@ -1724,6 +1763,7 @@ class Widget(DOMNode): speed=speed, duration=duration, easing=easing, + force=force, ) def scroll_to_widget( @@ -1735,6 +1775,7 @@ class Widget(DOMNode): duration: float | None = None, easing: EasingFunction | str | None = None, top: bool = False, + force: bool = False, ) -> bool: """Scroll scrolling to bring a widget in to view. @@ -1746,6 +1787,7 @@ class Widget(DOMNode): easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None", which will result in Textual choosing the configured default scrolling easing function. top (bool, optional): Scroll widget to top of container. Defaults to False. + force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`. Returns: bool: True if any scrolling has occurred in any descendant, otherwise False. @@ -1765,6 +1807,7 @@ class Widget(DOMNode): duration=duration, top=top, easing=easing, + force=force, ) if scroll_offset: scrolled = True @@ -1793,6 +1836,7 @@ class Widget(DOMNode): duration: float | None = None, easing: EasingFunction | str | None = None, top: bool = False, + force: bool = False, ) -> Offset: """Scrolls a given region in to view, if required. @@ -1808,6 +1852,7 @@ class Widget(DOMNode): easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None", which will result in Textual choosing the configured default scrolling easing function. top (bool, optional): Scroll region to top of container. Defaults to False. + force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`. Returns: Offset: The distance that was scrolled. @@ -1835,6 +1880,7 @@ class Widget(DOMNode): speed=speed, duration=duration, easing=easing, + force=force, ) return delta @@ -1846,6 +1892,7 @@ class Widget(DOMNode): duration: float | None = None, top: bool = False, easing: EasingFunction | str | None = None, + force: bool = False, ) -> None: """Scroll the container to make this widget visible. @@ -1856,6 +1903,7 @@ class Widget(DOMNode): top (bool, optional): Scroll to top of container. Defaults to False. easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None", which will result in Textual choosing the configured default scrolling easing function. + force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`. """ parent = self.parent if isinstance(parent, Widget): @@ -1867,6 +1915,7 @@ class Widget(DOMNode): duration=duration, top=top, easing=easing, + force=force, ) def __init_subclass__( diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 3587d30f2..0d4c1c315 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -195,8 +195,13 @@ class Button(Static, can_focus=True): self.variant = self.validate_variant(variant) label: Reactive[RenderableType] = Reactive("") + """The text label that appears within the button.""" + variant = Reactive.init("default") + """The variant name for the button.""" + disabled = Reactive(False) + """The disabled state of the button; `True` if disabled, `False` if not.""" def __rich_repr__(self) -> rich.repr.Result: yield from super().__rich_repr__() diff --git a/src/textual/widgets/_checkbox.py b/src/textual/widgets/_checkbox.py index bf0cdaf2a..9d8718884 100644 --- a/src/textual/widgets/_checkbox.py +++ b/src/textual/widgets/_checkbox.py @@ -57,9 +57,6 @@ class Checkbox(Widget, can_focus=True): "checkbox--switch", } - value = reactive(False, init=False) - slider_pos = reactive(0.0) - def __init__( self, value: bool = False, @@ -84,6 +81,12 @@ class Checkbox(Widget, can_focus=True): self._reactive_value = value self._should_animate = animate + value = reactive(False, init=False) + """The value of the checkbox; `True` for on and `False` for off.""" + + slider_pos = reactive(0.0) + """The position of the slider.""" + def watch_value(self, value: bool) -> None: target_slider_pos = 1.0 if value else 0.0 if self._should_animate: diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index ec68be8cd..a1abd0454 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -113,6 +113,17 @@ class Input(Widget, can_focus=True): id: str | None = None, classes: str | None = None, ) -> None: + """Initialise the `Input` widget. + + Args: + value (str | None, optional): An optional default value for the input. + placeholder (str, optional): Optional placeholder text for the input. + highlighter (Highlighter | None, optional): An optional highlighter for the input. + password (bool, optional): Flag to say if the field should obfuscate its content. Default is `False`. + name (str | None, optional): Optional name for the input widget. + id (str | None): Optional ID for the widget. + classes (str | None): Optional initial classes for the widget. + """ super().__init__(name=name, id=id, classes=classes) if value is not None: self.value = value @@ -127,7 +138,7 @@ class Input(Widget, can_focus=True): @property def _cursor_offset(self) -> int: - """Get the cell offset of the cursor.""" + """The cell offset of the cursor.""" offset = self._position_to_cell(self.cursor_position) if self._cursor_at_end: offset += 1 @@ -135,7 +146,7 @@ class Input(Widget, can_focus=True): @property def _cursor_at_end(self) -> bool: - """Check if the cursor is at the end""" + """Flag to indicate if the cursor is at the end""" return self.cursor_position >= len(self.value) def validate_cursor_position(self, cursor_position: int) -> int: @@ -170,7 +181,7 @@ class Input(Widget, can_focus=True): @property def cursor_width(self) -> int: - """Get the width of the input (with extra space for cursor at the end).""" + """The width of the input (with extra space for cursor at the end).""" if self.placeholder and not self.value: return cell_len(self.placeholder) return self._position_to_cell(len(self.value)) + 1 diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py index 0bfc8b899..f9903181b 100644 --- a/src/textual/widgets/_text_log.py +++ b/src/textual/widgets/_text_log.py @@ -19,10 +19,10 @@ from ..strip import Strip class TextLog(ScrollView, can_focus=True): - DEFAULT_CSS = """ + DEFAULT_CSS = """ TextLog{ background: $surface; - color: $text; + color: $text; overflow-y: scroll; } """ @@ -103,11 +103,11 @@ class TextLog(ScrollView, can_focus=True): container_width = ( self.scrollable_content_region.width if width is None else width ) - - if expand and render_width < container_width: - render_width = container_width - if shrink and render_width > container_width: - render_width = container_width + if container_width: + if expand and render_width < container_width: + render_width = container_width + if shrink and render_width > container_width: + render_width = container_width segments = self.app.console.render( renderable, render_options.update_width(render_width) @@ -120,7 +120,9 @@ class TextLog(ScrollView, can_focus=True): self.max_width, max(sum(segment.cell_length for segment in _line) for _line in lines), ) - strips = Strip.from_lines(lines, render_width) + strips = Strip.from_lines(lines) + for strip in strips: + strip.adjust_cell_length(render_width) self.lines.extend(strips) if self.max_lines is not None and len(self.lines) > self.max_lines: