From f6bf0429e88523ba1f096961a7e2b4e92ae764c4 Mon Sep 17 00:00:00 2001 From: Yorick van Pelt Date: Thu, 2 Oct 2025 09:10:45 +0200 Subject: [PATCH 1/3] Support the terminal color palette update notifications protocol --- src/textual/_types.py | 13 ++++++++++++- src/textual/_xterm_parser.py | 15 +++++++++++++++ src/textual/app.py | 9 ++++++++- src/textual/drivers/linux_driver.py | 5 +++++ src/textual/messages.py | 22 +++++++++++++++++++++- 5 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/textual/_types.py b/src/textual/_types.py index f37bae70c4..c87190ecab 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -1,4 +1,13 @@ -from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Literal, Union +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + List, + Literal, + Optional, + Union, +) from typing_extensions import Protocol @@ -55,3 +64,5 @@ class UnusedParameter: AnimationLevel = Literal["none", "basic", "full"] """The levels that the [`TEXTUAL_ANIMATIONS`][textual.constants.TEXTUAL_ANIMATIONS] env var can be set to.""" + +TerminalLightDarkMode = Optional[Literal["light", "dark"]] diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 6203163ccb..0da3d98979 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -22,6 +22,10 @@ _re_terminal_mode_response = re.compile( "^" + re.escape("\x1b[") + r"\?(?P\d+);(?P\d)\$y" ) +# DSR - Device Status Report, overly specific regex just for color modes +_re_dsr_response = re.compile( + "^" + re.escape("\x1b[") + r"\?(?P\d+);(?P\d+)n" +) _re_cursor_position = re.compile(r"\x1b\[(?P\d+);(?P\d+)R") @@ -308,6 +312,17 @@ def send_escape() -> None: ) on_token(in_band_event) break + dsr_report_match = _re_dsr_response.match(sequence) + if dsr_report_match is not None: + mode_id = dsr_report_match["param1"] + setting_parameter = dsr_report_match["param2"] + if mode_id == "997": + on_token( + messages.TerminalColorTheme.from_setting_parameter( + int(setting_parameter) + ) + ) + break if self._debug_log_file is not None: self._debug_log_file.close() diff --git a/src/textual/app.py b/src/textual/app.py index ad239dcea9..3863852ddc 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -87,7 +87,7 @@ _css_path_type_as_list, _make_path_object_relative, ) -from textual._types import AnimationLevel +from textual._types import AnimationLevel, TerminalLightDarkMode from textual._wait import wait_for_idle from textual.actions import ActionParseResult, SkipAction from textual.await_complete import AwaitComplete @@ -832,6 +832,9 @@ def __init__( self._compose_screen: Screen | None = None """The screen composed by App.compose.""" + self.terminal_light_dark_mode: TerminalLightDarkMode = None + """The current light/dark mode theme of the terminal, if known, or None otherwise.""" + if self.ENABLE_COMMAND_PALETTE: for _key, binding in self._bindings: if binding.action in {"command_palette", "app.command_palette"}: @@ -4410,6 +4413,10 @@ def _on_terminal_supports_synchronized_output( if self._driver is not None and not self._driver.is_inline: self._sync_available = True + def _on_terminal_color_theme(self, message: messages.TerminalColorTheme) -> None: + log.system("set color theme to", message.theme) + self.terminal_light_dark_mode = message.theme + def _begin_update(self) -> None: if self._sync_available and self._driver is not None: self._driver.write(SYNC_START) diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 66f4c4d978..82c61709e7 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -275,6 +275,10 @@ def on_terminal_resize(signum, stack) -> None: self.write("\x1b[?1004h") # Enable FocusIn/FocusOut. self.write("\x1b[>1u") # https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + # https://contour-terminal.org/vt-extensions/color-palette-update-notifications/ + self.write("\x1b[?996n") # Request current theme mode + self.write("\x1b[?2031h") # Enable color status reports + self.flush() self._key_thread = Thread(target=self._run_input_thread, name="textual-input") send_size_event() @@ -377,6 +381,7 @@ def stop_application_mode(self) -> None: self.write("\x1b[?1049l") self.write("\x1b[?25h") self.write("\x1b[?1004l") # Disable FocusIn/FocusOut. + self.write("\x1b[?2031l") # Disable color status reports self.flush() def close(self) -> None: diff --git a/src/textual/messages.py b/src/textual/messages.py index dc0f6544a8..af9e2bea22 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -4,7 +4,7 @@ import rich.repr -from textual._types import CallbackType +from textual._types import CallbackType, TerminalLightDarkMode from textual.geometry import Region from textual.message import Message @@ -134,3 +134,23 @@ def from_setting_parameter(cls, setting_parameter: int) -> InBandWindowResize: supported = setting_parameter not in (0, 4) enabled = setting_parameter in (1, 3) return InBandWindowResize(supported, enabled) + + +@rich.repr.auto +class TerminalColorTheme(Message): + """ + Reports the current light/dark mode setting on the terminal. + @link https://github.com/contour-terminal/contour/blob/c9492cd5c8d2be5d39eb76c7e72838bd44ff2f42/docs/vt-extensions/color-palette-update-notifications.md + """ + + def __init__(self, theme: TerminalLightDarkMode) -> None: + self.theme = theme + super().__init__() + + @classmethod + def from_setting_parameter(cls, setting_parameter: int) -> TerminalColorTheme: + if setting_parameter == 1: + return TerminalColorTheme("dark") + elif setting_parameter == 2: + return TerminalColorTheme("light") + return TerminalColorTheme(None) From c424498d59d068e7839df592a1a396503d86dc27 Mon Sep 17 00:00:00 2001 From: Yorick van Pelt Date: Thu, 2 Oct 2025 09:21:43 +0200 Subject: [PATCH 2/3] Add docstrings --- src/textual/_types.py | 1 + src/textual/messages.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/textual/_types.py b/src/textual/_types.py index c87190ecab..efce325f0d 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -66,3 +66,4 @@ class UnusedParameter: """The levels that the [`TEXTUAL_ANIMATIONS`][textual.constants.TEXTUAL_ANIMATIONS] env var can be set to.""" TerminalLightDarkMode = Optional[Literal["light", "dark"]] +"""Possible terminal color modes for App.terminal_light_dark_mode and messages.TerminalColorTheme""" diff --git a/src/textual/messages.py b/src/textual/messages.py index af9e2bea22..d8b899c9cd 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -144,11 +144,25 @@ class TerminalColorTheme(Message): """ def __init__(self, theme: TerminalLightDarkMode) -> None: + """Initialize message. + + Args: + theme: set to "dark" or "light" if known, None otherwise + """ self.theme = theme super().__init__() @classmethod def from_setting_parameter(cls, setting_parameter: int) -> TerminalColorTheme: + """Construct the message from the setting parameter. + + Args: + setting_parameter: Seting parameter from stdin. + + Returns: + New TerminalColorTheme instance. + """ + if setting_parameter == 1: return TerminalColorTheme("dark") elif setting_parameter == 2: From 1b6b4278df7732f75883839a57bda61d1613a515 Mon Sep 17 00:00:00 2001 From: Yorick van Pelt Date: Thu, 2 Oct 2025 09:21:48 +0200 Subject: [PATCH 3/3] Add changelog entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5200430ceb..e6dadc21cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 + +### Added + +- Added support for [terminal dark and light mode detection](https://github.com/contour-terminal/contour/blob/c9492cd5c8d2be5d39eb76c7e72838bd44ff2f42/docs/vt-extensions/color-palette-update-notifications.md) using `messages.TerminalColorTheme` https://github.com/Textualize/textual/pull/6152 + ## [6.2.1] - 2025-10-01 - Fix inability to copy text outside of an input/textarea when it was focused https://github.com/Textualize/textual/pull/6148