Skip to content

Commit 335c887

Browse files
committed
exp-feat: allow theme to be inherited from terminal
When using a modern terminal emulator such as kitty or ghostty, a new theme called `terminal-derived-theme` can be selected. This theme will infer the appropriate theme from the terminal palette colors. When an unsupported terminal emulator is used, a notification will be raised and the default selected. This feature is EXPERIMENTAL. But it seems to be working nicely with `ghostty` and `kitty`. Though in `kitty` there seems to be some flickering.
1 parent 4c9828a commit 335c887

File tree

3 files changed

+147
-4
lines changed

3 files changed

+147
-4
lines changed

demo.tape

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ Type@10ms "systemctl start --user '0-isd-example-unit-01.service' '0-isd-example
8282
# do not full refresh during recording -> Set it to a very large value
8383
# normal refresh should use the default value!
8484
# `my_asciinema` is simply `asciinema_3` wrapped in a bash script, where a custom `config.toml` is utilized.
85-
Type@10ms "ISD_CACHE_INPUT=false ISD_STARTUP_MODE=user ISD_JOURNAL_PAGER=lnav ISD_DEFAULT_PAGER=moar ISD_FULL_REFRESH_INTERVAL_SEC=1000 my_asciinema rec --overwrite --quiet --command isd ./docs/assets/images/isd.cast"
85+
Type@10ms "XDG_CACHE_HOME=$(mktemp -d) XDG_CONFIG_HOME=$(mktemp -d) ISD_STARTUP_MODE=user ISD_JOURNAL_PAGER=lnav ISD_DEFAULT_PAGER=moar ISD_FULL_REFRESH_INTERVAL_SEC=1000 my_asciinema rec --overwrite --quiet --command isd ./docs/assets/images/isd.cast"
8686
Enter
8787

8888
# # Sleep 2s
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import re
2+
import sys
3+
import tty
4+
import termios
5+
import select
6+
from typing import Optional
7+
from textual.theme import Theme
8+
9+
# OSC Codes:
10+
# https://chromium.googlesource.com/apps/libapps/+/a5fb83c190aa9d74f4a9bca233dac6be2664e9e9/hterm/doc/ControlSequences.md#OSC
11+
12+
13+
def send_osc_query(seq) -> str:
14+
"""
15+
Send an OSC query. To avoid locking up when something
16+
unexpected happens, the terminal has 1 second to respond
17+
and cannot write more than 1024 characters.
18+
If there is any issue, an empty string is returned.
19+
"""
20+
fd = sys.stdin.fileno()
21+
old_settings = termios.tcgetattr(fd)
22+
response = ""
23+
try:
24+
tty.setcbreak(fd)
25+
26+
# tty.setraw(fd)
27+
sys.stdout.write(seq)
28+
sys.stdout.flush()
29+
timeout_s = 1
30+
# I am using a timeout when reading from `stdin`
31+
# to ensure that this won't block forever if the terminal
32+
# behaves oddly. This may only work on `UNIX` but
33+
# that should be fine for now.
34+
ready, _, _ = select.select([fd], [], [], timeout_s)
35+
last_char_was_esc = False
36+
if ready:
37+
for _ in range(1024):
38+
c = sys.stdin.read(1)
39+
if c == "\a" or (c == "\x5c" and last_char_was_esc):
40+
break
41+
42+
if c == "\x1b":
43+
last_char_was_esc = True
44+
continue
45+
else:
46+
last_char_was_esc = False
47+
response += c
48+
else:
49+
response = ""
50+
51+
finally:
52+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
53+
return response
54+
55+
56+
def parse_rgb(resp: str) -> Optional[str]:
57+
match = re.search(r"rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)", resp)
58+
if match:
59+
# TODO: At some point figure out why ghostty return RR
60+
# -> Seems to be the spec
61+
rgb = "".join(m[:2] for m in match.groups())
62+
return "#" + rgb
63+
else:
64+
return None
65+
66+
67+
def query_palette_color(index: int) -> Optional[str]:
68+
"""Send OSC 4 query and parse reply."""
69+
seq = f"\033]4;{index};?\a"
70+
response = send_osc_query(seq)
71+
return parse_rgb(response)
72+
73+
74+
def query_background_color() -> Optional[str]:
75+
seq = "\033]11;?\a"
76+
response = send_osc_query(seq)
77+
return parse_rgb(response)
78+
79+
80+
def query_foreground_color() -> Optional[str]:
81+
seq = "\033]10;?\a"
82+
response = send_osc_query(seq)
83+
return parse_rgb(response)
84+
85+
86+
def derive_textual_theme() -> Optional[Theme]:
87+
"""
88+
Derive a textual theme by quering the current terminal
89+
via OSC escape codes.
90+
91+
Here, we assume that an iTerm2 color scheme (https://github.com/mbadolato/iTerm2-Color-Schemes) is used.
92+
This function may return `None` if the theme could not be derived.
93+
This may happen if the terminal emulator does not support the `OSC 4` query extension
94+
to retrieve the current palette.
95+
If this is the case, use a modern terminal emulator such as `ghostty` or `kitty`.
96+
97+
This function REQUIRES textual to suspend the current application!
98+
"""
99+
fg = query_foreground_color()
100+
if fg is None:
101+
return None
102+
bg = query_background_color()
103+
104+
if bg is None:
105+
return None
106+
107+
iterm_colors = []
108+
for i in range(15):
109+
iterm_color = query_palette_color(i)
110+
if iterm_color is None:
111+
return None
112+
iterm_colors.append(iterm_color)
113+
114+
return Theme(
115+
name="terminal-derived-theme",
116+
primary=iterm_colors[4],
117+
secondary=iterm_colors[2],
118+
accent=iterm_colors[3],
119+
foreground=fg,
120+
background=bg,
121+
success=iterm_colors[10],
122+
warning=iterm_colors[11],
123+
error=iterm_colors[9],
124+
surface=bg,
125+
panel=iterm_colors[8],
126+
boost=iterm_colors[0],
127+
)

src/isd_tui/isd.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
)
103103
from textual.widgets.selection_list import Selection
104104
from textual.widgets.option_list import Option
105+
from .derive_terminal_theme import derive_textual_theme
105106

106107

107108
# make type checker happy.
@@ -154,8 +155,9 @@
154155
AUTHENTICATION_MODE = "sudo"
155156
# AUTHENTICATION_MODE = "polkit"
156157

157-
# HERE: Remove this from the global scope for testing!
158-
Theme = StrEnum("Theme", [key for key in BUILTIN_THEMES.keys()]) # type: ignore
158+
Theme = StrEnum(
159+
"Theme", [key for key in BUILTIN_THEMES.keys()] + ["terminal-derived-theme"]
160+
) # type: ignore
159161
StartupMode = StrEnum("StartupMode", ["user", "system", "auto"])
160162

161163
SETTINGS_YAML_HEADER = dedent("""\
@@ -2840,7 +2842,21 @@ def action_show_version(self) -> None:
28402842
def on_mount(self) -> None:
28412843
# The theme should be loaded very early,
28422844
# as the theme change can be quite jarring.
2843-
self.theme = self.settings.theme
2845+
t = self.settings.theme
2846+
if t == "terminal-derived-theme":
2847+
with self.app.suspend():
2848+
derived_theme = derive_textual_theme()
2849+
if derived_theme is not None:
2850+
self.register_theme(derived_theme)
2851+
self.theme = t
2852+
else:
2853+
self.notify(
2854+
"Could not derive theme from terminal. Use a modern terminal such as ghostty or kitty instead."
2855+
)
2856+
self.theme = "textual-dark"
2857+
else:
2858+
self.theme = t
2859+
28442860
# Always make sure to use the latest schema
28452861
self.update_schema()
28462862
self.install_screen(MainScreen(self.settings), "main")

0 commit comments

Comments
 (0)