diff --git a/craft_cli/messages.py b/craft_cli/messages.py index af0a8b3f..e1cf5668 100644 --- a/craft_cli/messages.py +++ b/craft_cli/messages.py @@ -602,7 +602,12 @@ def _get_progress_params( return stream, use_timestamp, ephemeral @_active_guard() - def progress(self, text: str, permanent: bool = False) -> None: # noqa: FBT001, FBT002 + def progress( + self, + text: str, + permanent: bool = False, # noqa: FBT001, FBT002 + update_titlebar: bool = False, # noqa: FBT001, FBT002 + ) -> None: """Progress information for a multi-step command. This is normally used to present several separated text messages. @@ -610,6 +615,9 @@ def progress(self, text: str, permanent: bool = False) -> None: # noqa: FBT001, If a progress message is important enough that it should not be overwritten by the next ones, use 'permanent=True'. + If a progress message describes an important step that you want to be visible, use + 'update_titlebar=True' to also set it as the titlebar text in the terminal window. + These messages will be truncated to the terminal's width, and overwritten by the next line (unless verbose/trace mode). """ @@ -625,6 +633,9 @@ def progress(self, text: str, permanent: bool = False) -> None: # noqa: FBT001, # Set the "progress prefix" for upcoming non-permanent messages. self._printer.set_terminal_prefix(text) + if update_titlebar: + self._printer.set_titlebar(stream, text) + @_active_guard() def progress_bar( self, text: str, total: float, delta: bool = True # noqa: FBT001, FBT002 diff --git a/craft_cli/printer.py b/craft_cli/printer.py index 1a230d80..55d0c0d4 100644 --- a/craft_cli/printer.py +++ b/craft_cli/printer.py @@ -21,6 +21,7 @@ import math import queue import shutil +import sys import threading import time from dataclasses import dataclass, field @@ -371,6 +372,15 @@ def show( # noqa: PLR0913 (too many parameters) if not avoid_logging: self._log(msg) + def set_titlebar(self, stream: TextIO | None, text: str) -> None: + """Set 'text' as the window titlebar content.""" + if _stream_is_terminal(stream): + # Sends the text with the right ANSI codes: + # ESC]2;textoBEL + if stream == sys.stderr: + stream = sys.stdout + print(f"\033]2;{text}\007", flush=True, file=stream, end="") + def progress_bar( # noqa: PLR0913 self, stream: TextIO | None, diff --git a/examples.py b/examples.py index ebd95709..0cbfd303 100755 --- a/examples.py +++ b/examples.py @@ -486,6 +486,21 @@ def _call_lib(logger, index): time.sleep(2) +def example_30(new_title): + """Set the window title""" + emit.progress(new_title, update_titlebar=True) + time.sleep(1.5) + + +def example_31(): + """Set the window title twice, to test if there is a delay when + changing the title""" + emit.progress("Changed the title once", update_titlebar=True) + time.sleep(2) + emit.progress("Changed the title twice", update_titlebar=True) + time.sleep(2) + + # -- end of test cases if len(sys.argv) < 2: diff --git a/tests/integration/test_messages_integration.py b/tests/integration/test_messages_integration.py index 658f6d25..49ff01e8 100644 --- a/tests/integration/test_messages_integration.py +++ b/tests/integration/test_messages_integration.py @@ -301,6 +301,32 @@ def test_progress_verbose(capsys, permanent): assert_outputs(capsys, emit, expected_err=expected, expected_log=expected) +@pytest.mark.parametrize("output_is_terminal", [False]) +def test_title_set_no_tty(capsys, monkeypatch): + """Show a progress message with update_title flag.""" + emit = Emitter() + emit.init(EmitterMode.BRIEF, "testapp", GREETING) + emit.progress("The meaning of life is 42.", update_titlebar=True) + emit.ended_ok() + + out, err = capsys.readouterr() + assert out.find("\x1b]2;The meaning of life is 42.\x07") is -1 + assert err.find("\x1b]2;The meaning of life is 42.\x07") is -1 + + +@pytest.mark.parametrize("output_is_terminal", [True]) +def test_title_set_in_tty(capsys, monkeypatch): + """Show a progress message with update_title flag.""" + emit = Emitter() + emit.init(EmitterMode.BRIEF, "testapp", GREETING) + emit.progress("The meaning of life is 42.", update_titlebar=True) + emit.ended_ok() + + out, err = capsys.readouterr() + assert out == "\x1b]2;The meaning of life is 42.\x07" + assert err.find("\x1b]2;The meaning of life is 42.\x07") is -1 + + @pytest.mark.parametrize( "mode", [ diff --git a/tests/unit/test_printer.py b/tests/unit/test_printer.py index 02d838e2..fddf460c 100644 --- a/tests/unit/test_printer.py +++ b/tests/unit/test_printer.py @@ -16,6 +16,7 @@ """Tests that check the whole Printer machinery.""" +import io import re import shutil import sys @@ -116,6 +117,60 @@ def isatty(self): assert result is False +# -- tests for terminal titlebar + + +def test_titlebar_no_tty(log_filepath): + """Setting the titlebar to a no-tty stream does nothing""" + + class FakeStream(io.StringIO): + def __init__(self): + self.output = "" + self.flushed = 0 + + def isatty(self): + return False + + def write(self, data): + self.output += data + + def flush(self): + self.flushed += 1 + + stream = FakeStream() + text = "test text" + printer = Printer(log_filepath) + printer.set_titlebar(stream, text) + assert stream.output == "" + assert stream.flushed == 0 + + +def test_titlebar_true_tty(log_filepath): + """Setting the titlebar to a true-tty stream sends the text and + the corresponding ANSI escape codes to set the title""" + + class FakeStream(io.StringIO): + def __init__(self): + self.output = "" + self.flushed = 0 + + def isatty(self): + return True + + def write(self, data): + self.output += data + + def flush(self): + self.flushed += 1 + + stream = FakeStream() + text = "test text" + printer = Printer(log_filepath) + printer.set_titlebar(stream, text) + assert stream.output == f"\033]2;{text}\007" + assert stream.flushed == 1 + + # -- tests for the writing line (terminal version) function