Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion src/textual/_types.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -55,3 +64,6 @@ 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"]]
"""Possible terminal color modes for App.terminal_light_dark_mode and messages.TerminalColorTheme"""
15 changes: 15 additions & 0 deletions src/textual/_xterm_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
_re_terminal_mode_response = re.compile(
"^" + re.escape("\x1b[") + r"\?(?P<mode_id>\d+);(?P<setting_parameter>\d)\$y"
)
# DSR - Device Status Report, overly specific regex just for color modes
_re_dsr_response = re.compile(
"^" + re.escape("\x1b[") + r"\?(?P<param1>\d+);(?P<param2>\d+)n"
)

_re_cursor_position = re.compile(r"\x1b\[(?P<row>\d+);(?P<col>\d+)R")

Expand Down Expand Up @@ -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()
Expand Down
9 changes: 8 additions & 1 deletion src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}:
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions src/textual/drivers/linux_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
36 changes: 35 additions & 1 deletion src/textual/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -134,3 +134,37 @@ 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:
"""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:
return TerminalColorTheme("light")
return TerminalColorTheme(None)