diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf376002b..e5d964375d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## Unreleased +## [0.7.0] - 2022-12-17 ### Added @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - When looking for bindings that have priority, they are now looked from `App` downwards. https://github.com/Textualize/textual/issues/1343 - `BINDINGS` on an `App`-derived class have priority by default. https://github.com/Textualize/textual/issues/1343 - `BINDINGS` on a `Screen`-derived class have priority by default. https://github.com/Textualize/textual/issues/1343 +- Added a message parameter to Widget.exit ### Fixed @@ -265,7 +266,8 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 - New handler system for messages that doesn't require inheritance - Improved traceback handling -[0.6.0]: https://github.com/Textualize/textual/compare/v0.3.0...v0.6.0 +[0.7.0]: https://github.com/Textualize/textual/compare/v0.6.0...v0.7.0 +[0.6.0]: https://github.com/Textualize/textual/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/Textualize/textual/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/Textualize/textual/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/Textualize/textual/compare/v0.2.1...v0.3.0 diff --git a/pyproject.toml b/pyproject.toml index 63ae6f2ee8..91404e3b42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.6.0" +version = "0.7.0" homepage = "https://github.com/Textualize/textual" description = "Modern Text User Interface framework" authors = ["Will McGugan "] diff --git a/src/textual/app.py b/src/textual/app.py index ba04effa18..a6b7ad27aa 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -350,6 +350,7 @@ def __init__( self.devtools = DevtoolsClient() self._return_value: ReturnType | None = None + self._exit = False self.css_monitor = ( FileMonitor(self.css_path, self._on_css_change) @@ -416,14 +417,20 @@ def screen_stack(self) -> list[Screen]: """list[Screen]: A *copy* of the screen stack.""" return self._screen_stack.copy() - def exit(self, result: ReturnType | None = None) -> None: + def exit( + self, result: ReturnType | None = None, message: RenderableType | None = None + ) -> None: """Exit the app, and return the supplied result. Args: result (ReturnType | None, optional): Return value. Defaults to None. + message (RenderableType | None): Optional message to display on exit. """ + self._exit = True self._return_value = result self.post_message_no_wait(messages.ExitApp(sender=self)) + if message: + self._exit_renderables.append(message) @property def focused(self) -> Widget | None: @@ -1082,9 +1089,9 @@ def _get_screen(self, screen: Screen | str) -> tuple[Screen, AwaitMount]: _screen = self.get_screen(screen) if not _screen.is_running: widgets = self._register(self, _screen) - return (_screen, AwaitMount(widgets)) + return (_screen, AwaitMount(_screen, widgets)) else: - return (_screen, AwaitMount([])) + return (_screen, AwaitMount(_screen, [])) def _replace_screen(self, screen: Screen) -> Screen: """Handle the replaced screen. @@ -1130,7 +1137,7 @@ def switch_screen(self, screen: Screen | str) -> AwaitMount: self.screen.post_message_no_wait(events.ScreenResume(self)) self.log.system(f"{self.screen} is current (SWITCHED)") return await_mount - return AwaitMount([]) + return AwaitMount(self.screen, []) def install_screen(self, screen: Screen, name: str | None = None) -> AwaitMount: """Install a screen. @@ -1408,28 +1415,29 @@ async def invoke_ready_callback() -> None: ) driver = self._driver = driver_class(self.console, self, size=terminal_size) - driver.start_application_mode() - try: - if headless: - await run_process_messages() - else: - if self.devtools is not None: - devtools = self.devtools - assert devtools is not None - from .devtools.redirect_output import StdoutRedirector - - redirector = StdoutRedirector(devtools) - with redirect_stderr(redirector): - with redirect_stdout(redirector): # type: ignore - await run_process_messages() + if not self._exit: + driver.start_application_mode() + try: + if headless: + await run_process_messages() else: - null_file = _NullFile() - with redirect_stderr(null_file): - with redirect_stdout(null_file): - await run_process_messages() + if self.devtools is not None: + devtools = self.devtools + assert devtools is not None + from .devtools.redirect_output import StdoutRedirector + + redirector = StdoutRedirector(devtools) + with redirect_stderr(redirector): + with redirect_stdout(redirector): # type: ignore + await run_process_messages() + else: + null_file = _NullFile() + with redirect_stderr(null_file): + with redirect_stdout(null_file): + await run_process_messages() - finally: - driver.stop_application_mode() + finally: + driver.stop_application_mode() except Exception as error: self._handle_exception(error) @@ -1565,7 +1573,6 @@ def _register( if widget.children: self._register(widget, *widget.children) apply_stylesheet(widget) - return list(widgets) def _unregister(self, widget: Widget) -> None: @@ -1735,13 +1742,12 @@ async def check_bindings(self, key: str, priority: bool = False) -> bool: """Handle a key press. Args: - key (str): A key + key (str): A key. priority (bool): If `True` check from `App` down, otherwise from focused up. Returns: bool: True if the key was handled by a binding, otherwise False """ - for namespace, bindings in ( reversed(self._binding_chain) if priority else self._binding_chain ): @@ -2046,7 +2052,8 @@ async def _prune_node(self, root: Widget) -> None: self._unregister(root) async def action_check_bindings(self, key: str) -> None: - await self.check_bindings(key) + if not await self.check_bindings(key, priority=True): + await self.check_bindings(key, priority=False) async def action_quit(self) -> None: """Quit the app as soon as possible.""" diff --git a/src/textual/binding.py b/src/textual/binding.py index 7b34f12f07..7170d3dfa5 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -77,9 +77,11 @@ def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]: description=binding.description, show=binding.show, key_display=binding.key_display, - priority=default_priority - if binding.priority is None - else binding.priority, + priority=( + default_priority + if binding.priority is None + else binding.priority + ), ) self.keys: MutableMapping[str, Binding] = ( diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index e181af84a8..48aa851473 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -36,20 +36,21 @@ VALID_TEXT_ALIGN: Final = {"start", "end", "left", "right", "center", "justify"} VALID_SCROLLBAR_GUTTER: Final = {"auto", "stable"} VALID_STYLE_FLAGS: Final = { - "none", - "not", - "bold", + "b", "blink", + "bold", + "dim", + "i", "italic", - "underline", + "none", + "not", + "o", "overline", + "reverse", "strike", - "b", - "i", "u", + "underline", "uu", - "o", - "reverse", } NULL_SPACING: Final = Spacing.all(0) diff --git a/src/textual/dom.py b/src/textual/dom.py index b5ccaea003..2ddaf9757a 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -517,8 +517,8 @@ def text_style(self) -> Style: @property def rich_style(self) -> Style: """Get a Rich Style object for this DOMNode.""" - background = WHITE - color = BLACK + background = Color(0, 0, 0, 0) + color = Color(255, 255, 255, 0) style = Style() for node in reversed(self.ancestors_with_self): styles = node.styles @@ -530,7 +530,8 @@ def rich_style(self) -> Style: if styles.has_rule("auto_color") and styles.auto_color: color = background.get_contrast_text(color.a) style += Style.from_color( - (background + color).rich_color, background.rich_color + (background + color).rich_color if (background.a or color.a) else None, + background.rich_color if background.a else None, ) return style diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 26520c6b95..e29790e304 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -320,7 +320,7 @@ def get_scroll_to_visible( Offset: An offset required to add to region to move it inside window_region. """ - if region in window_region: + if region in window_region and not top: # Region is already inside the window, so no need to move it. return NULL_OFFSET @@ -341,19 +341,19 @@ def get_scroll_to_visible( key=abs, ) - if not ( + if top: + delta_y = top_ - window_top + + elif not ( (window_bottom > top_ >= window_top) and (window_bottom > bottom >= window_top) ): # The window needs to scroll on the Y axis to bring region in to view - if top: - delta_y = top_ - window_top - else: - delta_y = min( - top_ - window_top, - top_ - (window_bottom - region.height), - key=abs, - ) + delta_y = min( + top_ - window_top, + top_ - (window_bottom - region.height), + key=abs, + ) return Offset(delta_x, delta_y) def __bool__(self) -> bool: diff --git a/src/textual/screen.py b/src/textual/screen.py index c08bd20b4e..0de76479e4 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -297,6 +297,7 @@ def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None: self.focused.post_message_no_wait(events.Blur(self)) self.focused.emit_no_wait(events.DescendantBlur(self)) self.focused = None + self.log.debug("focus was removed") elif widget.can_focus: if self.focused != widget: if self.focused is not None: @@ -310,6 +311,7 @@ def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None: self.screen.scroll_to_widget(widget) widget.post_message_no_wait(events.Focus(self)) widget.emit_no_wait(events.DescendantFocus(self)) + self.log.debug(widget, "was focused") async def _on_idle(self, event: events.Idle) -> None: # Check for any widgets marked as 'dirty' (needs a repaint) diff --git a/src/textual/widget.py b/src/textual/widget.py index 31344f58cc..1d5f4365bc 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -85,7 +85,8 @@ class AwaitMount: """ - def __init__(self, widgets: Sequence[Widget]) -> None: + def __init__(self, parent: Widget, widgets: Sequence[Widget]) -> None: + self._parent = parent self._widgets = widgets def __await__(self) -> Generator[None, None, None]: @@ -97,6 +98,7 @@ async def await_mount() -> None: ] if aws: await wait(aws) + self._parent.refresh(layout=True) return await_mount().__await__() @@ -595,12 +597,12 @@ def mount( else: parent = self - return AwaitMount( - self.app._register( - parent, *widgets, before=insert_before, after=insert_after - ) + mounted = self.app._register( + parent, *widgets, before=insert_before, after=insert_after ) + return AwaitMount(self, mounted) + def move_child( self, child: int | Widget, @@ -1805,7 +1807,7 @@ def scroll_to_region( if spacing is not None: window = window.shrink(spacing) - if window in region: + if window in region and not top: return Offset() delta_x, delta_y = Region.get_scroll_to_visible(window, region, top=top) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 7b19dadce3..de61976be3 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -451,6 +451,7 @@ def get_label_width(self, node: TreeNode[TreeDataType]) -> int: def clear(self) -> None: """Clear all nodes under root.""" + self._line_cache.clear() self._tree_lines_cached = None self._current_id = 0 root_label = self.root._label @@ -614,6 +615,11 @@ def _tree_lines(self) -> list[_TreeLine]: assert self._tree_lines_cached is not None return self._tree_lines_cached + def _on_idle(self) -> None: + """Check tree needs a rebuild on idle.""" + # Property calls build if required + self._tree_lines + def _build(self) -> None: """Builds the tree by traversing nodes, and creating tree lines.""" @@ -805,7 +811,7 @@ async def _on_click(self, event: events.Click) -> None: cursor_line = meta["line"] if meta.get("toggle", False): node = self.get_node_at_line(cursor_line) - if node is not None and self.auto_expand: + if node is not None: self._toggle_node(node) else: