diff --git a/CHANGELOG.md b/CHANGELOG.md index a210e951e..e66cb4e19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Ability to change terminal window title https://github.com/Textualize/rich/pull/2200 + ### Fixed - Fall back to `sys.__stderr__` on POSIX systems when trying to get the terminal size (fix issues when Rich is piped to another process) @@ -25,9 +29,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Progress.open and Progress.wrap_file method to track the progress while reading from a file or file-like object https://github.com/willmcgugan/rich/pull/1759 - SVG export functionality https://github.com/Textualize/rich/pull/2101 - -### Added - - Adding Indonesian translation ### Fixed diff --git a/rich/_windows_renderer.py b/rich/_windows_renderer.py index 514c45115..0fc2ba852 100644 --- a/rich/_windows_renderer.py +++ b/rich/_windows_renderer.py @@ -51,3 +51,6 @@ def legacy_windows_render(buffer: Iterable[Segment], term: LegacyWindowsTerm) -> term.erase_start_of_line() elif mode == 2: term.erase_line() + elif control_type == ControlType.SET_WINDOW_TITLE: + _, title = cast(Tuple[ControlType, str], control_code) + term.set_title(title) diff --git a/rich/console.py b/rich/console.py index c2b19955d..12670c44b 100644 --- a/rich/console.py +++ b/rich/console.py @@ -1181,6 +1181,38 @@ def is_alt_screen(self) -> bool: """ return self._is_alt_screen + def set_window_title(self, title: str) -> bool: + """Set the title of the console terminal window. + + Warning: There is no means within Rich of "resetting" the window title to its + previous value, meaning the title you set will persist even after your application + exits. + + ``fish`` shell resets the window title before and after each command by default, + negating this issue. Windows Terminal and command prompt will also reset the title for you. + Most other shells and terminals, however, do not do this. + + Some terminals may require configuration changes before you can set the title. + Some terminals may not support setting the title at all. + + Other software (including the terminal itself, the shell, custom prompts, plugins, etc.) + may also set the terminal window title. This could result in whatever value you write + using this method being overwritten. + + Args: + title (str): The new title of the terminal window. + + Returns: + bool: True if the control code to change the terminal title was + written, otherwise False. Note that a return value of True + does not guarantee that the window title has actually changed, + since the feature may be unsupported/disabled in some terminals. + """ + if self.is_terminal: + self.control(Control.title(title)) + return True + return False + def screen( self, hide_cursor: bool = True, style: Optional[StyleType] = None ) -> "ScreenContext": diff --git a/rich/control.py b/rich/control.py index e17b2c634..747311fce 100644 --- a/rich/control.py +++ b/rich/control.py @@ -1,4 +1,5 @@ -from typing import Callable, Dict, Iterable, List, TYPE_CHECKING, Union +import time +from typing import TYPE_CHECKING, Callable, Dict, Iterable, List, Union from .segment import ControlCode, ControlType, Segment @@ -30,6 +31,7 @@ ControlType.CURSOR_MOVE_TO_COLUMN: lambda param: f"\x1b[{param+1}G", ControlType.ERASE_IN_LINE: lambda param: f"\x1b[{param}K", ControlType.CURSOR_MOVE_TO: lambda x, y: f"\x1b[{y+1};{x+1}H", + ControlType.SET_WINDOW_TITLE: lambda title: f"\x1b]0;{title}\x07", } @@ -147,6 +149,15 @@ def alt_screen(cls, enable: bool) -> "Control": else: return cls(ControlType.DISABLE_ALT_SCREEN) + @classmethod + def title(cls, title: str) -> "Control": + """Set the terminal window title + + Args: + title (str): The new terminal window title + """ + return cls((ControlType.SET_WINDOW_TITLE, title)) + def __str__(self) -> str: return self.segment.text @@ -172,4 +183,11 @@ def strip_control_codes( if __name__ == "__main__": # pragma: no cover - print(strip_control_codes("hello\rWorld")) + from rich.console import Console + + console = Console() + console.print("Look at the title of your terminal window ^") + # console.print(Control((ControlType.SET_WINDOW_TITLE, "Hello, world!"))) + for i in range(10): + console.set_window_title("🚀 Loading" + "." * i) + time.sleep(0.5) diff --git a/rich/segment.py b/rich/segment.py index 5e37f93a5..1890cb617 100644 --- a/rich/segment.py +++ b/rich/segment.py @@ -49,10 +49,13 @@ class ControlType(IntEnum): CURSOR_MOVE_TO_COLUMN = 13 CURSOR_MOVE_TO = 14 ERASE_IN_LINE = 15 + SET_WINDOW_TITLE = 16 ControlCode = Union[ - Tuple[ControlType], Tuple[ControlType, int], Tuple[ControlType, int, int] + Tuple[ControlType], + Tuple[ControlType, Union[int, str]], + Tuple[ControlType, int, int], ] diff --git a/tests/test_console.py b/tests/test_console.py index 4a5536c87..3305202bd 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -877,6 +877,18 @@ def test_is_alt_screen(): assert not console.is_alt_screen +def test_set_console_title(): + console = Console(force_terminal=True, _environ={}) + if console.legacy_windows: + return + + with console.capture() as captured: + console.set_window_title("hello") + + result = captured.get() + assert result == "\x1b]0;hello\x07" + + def test_update_screen(): console = Console(force_terminal=True, width=20, height=5, _environ={}) if console.legacy_windows: diff --git a/tests/test_control.py b/tests/test_control.py index dbd21fe11..987206d82 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -1,5 +1,5 @@ from rich.control import Control, strip_control_codes -from rich.segment import Segment, ControlType +from rich.segment import ControlType, Segment def test_control(): @@ -45,3 +45,12 @@ def test_move_to_column(): None, [(ControlType.CURSOR_MOVE_TO_COLUMN, 10), (ControlType.CURSOR_UP, 20)], ) + + +def test_title(): + control_segment = Control.title("hello").segment + assert control_segment == Segment( + "\x1b]0;hello\x07", + None, + [(ControlType.SET_WINDOW_TITLE, "hello")], + ) diff --git a/tests/test_live.py b/tests/test_live.py index eeb6ab6e4..8e3d58928 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -4,8 +4,8 @@ # import pytest from rich.console import Console -from rich.text import Text from rich.live import Live +from rich.text import Text def create_capture_console( @@ -116,8 +116,6 @@ def test_growing_display_overflow_visible() -> None: def test_growing_display_autorefresh() -> None: """Test generating a table but using auto-refresh from threading""" - console = create_capture_console() - console = create_capture_console(height=5) console.begin_capture() with Live(console=console, auto_refresh=True, vertical_overflow="visible") as live: diff --git a/tests/test_windows_renderer.py b/tests/test_windows_renderer.py index ff2b273a7..bc24ae95e 100644 --- a/tests/test_windows_renderer.py +++ b/tests/test_windows_renderer.py @@ -131,3 +131,11 @@ def test_control_cursor_move_to_column(legacy_term_mock): legacy_windows_render(buffer, legacy_term_mock) legacy_term_mock.move_cursor_to_column.assert_called_once_with(2) + + +def test_control_set_terminal_window_title(legacy_term_mock): + buffer = [Segment("", None, [(ControlType.SET_WINDOW_TITLE, "Hello, world!")])] + + legacy_windows_render(buffer, legacy_term_mock) + + legacy_term_mock.set_title.assert_called_once_with("Hello, world!")