Skip to content

Commit 23a647a

Browse files
Support mode 2031 dark/light mode detection (#14356)
1 parent c2b582a commit 23a647a

File tree

8 files changed

+150
-9
lines changed

8 files changed

+150
-9
lines changed

book/src/themes.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
To use a theme add `theme = "<name>"` to the top of your [`config.toml`](./configuration.md) file, or select it during runtime using `:theme <name>`.
44

5+
Separate themes can be configured for light and dark modes. On terminals supporting [mode 2031 dark/light detection](https://github.com/contour-terminal/contour/blob/master/docs/vt-extensions/color-palette-update-notifications.md), the theme mode is detected from the terminal.
6+
7+
```toml
8+
[theme]
9+
dark = "catppuccin_frappe"
10+
light = "catppuccin_latte"
11+
## Optional. Used if the terminal doesn't declare a preference.
12+
## Defaults to the theme set for `dark` if not specified.
13+
# fallback = "catppuccin_frappe"
14+
```
15+
516
## Creating a theme
617

718
Create a file with the name of your theme as the file name (i.e `mytheme.toml`) and place it in your `themes` directory (i.e `~/.config/helix/themes` or `%AppData%\helix\themes` on Windows). The directory might have to be created beforehand.

helix-term/src/application.rs

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ pub struct Application {
7777
signals: Signals,
7878
jobs: Jobs,
7979
lsp_progress: LspProgressMap,
80+
81+
theme_mode: Option<theme::Mode>,
8082
}
8183

8284
#[cfg(feature = "integration")]
@@ -121,6 +123,7 @@ impl Application {
121123
#[cfg(feature = "integration")]
122124
let backend = TestBackend::new(120, 150);
123125

126+
let theme_mode = backend.get_theme_mode();
124127
let terminal = Terminal::new(backend)?;
125128
let area = terminal.size().expect("couldn't get terminal size");
126129
let mut compositor = Compositor::new(area);
@@ -139,6 +142,7 @@ impl Application {
139142
&mut editor,
140143
&config.load(),
141144
terminal.backend().supports_true_color(),
145+
theme_mode,
142146
);
143147

144148
let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| {
@@ -258,6 +262,7 @@ impl Application {
258262
signals,
259263
jobs: Jobs::new(),
260264
lsp_progress: LspProgressMap::new(),
265+
theme_mode,
261266
};
262267

263268
Ok(app)
@@ -416,6 +421,7 @@ impl Application {
416421
&mut self.editor,
417422
&default_config,
418423
self.terminal.backend().supports_true_color(),
424+
self.theme_mode,
419425
);
420426

421427
// Re-parse any open documents with the new language config.
@@ -449,12 +455,18 @@ impl Application {
449455
}
450456

451457
/// Load the theme set in configuration
452-
fn load_configured_theme(editor: &mut Editor, config: &Config, terminal_true_color: bool) {
458+
fn load_configured_theme(
459+
editor: &mut Editor,
460+
config: &Config,
461+
terminal_true_color: bool,
462+
mode: Option<theme::Mode>,
463+
) {
453464
let true_color = terminal_true_color || config.editor.true_color || crate::true_color();
454465
let theme = config
455466
.theme
456467
.as_ref()
457-
.and_then(|theme| {
468+
.and_then(|theme_config| {
469+
let theme = theme_config.choose(mode);
458470
editor
459471
.theme_loader
460472
.load(theme)
@@ -672,6 +684,9 @@ impl Application {
672684
}
673685

674686
pub async fn handle_terminal_events(&mut self, event: std::io::Result<TerminalEvent>) {
687+
#[cfg(not(windows))]
688+
use termina::escape::csi;
689+
675690
let mut cx = crate::compositor::Context {
676691
editor: &mut self.editor,
677692
jobs: &mut self.jobs,
@@ -698,6 +713,16 @@ impl Application {
698713
kind: termina::event::KeyEventKind::Release,
699714
..
700715
}) => false,
716+
#[cfg(not(windows))]
717+
termina::Event::Csi(csi::Csi::Mode(csi::Mode::ReportTheme(mode))) => {
718+
Self::load_configured_theme(
719+
&mut self.editor,
720+
&self.config.load(),
721+
self.terminal.backend().supports_true_color(),
722+
Some(mode.into()),
723+
);
724+
true
725+
}
701726
#[cfg(windows)]
702727
TerminalEvent::Resize(width, height) => {
703728
self.terminal
@@ -1167,9 +1192,16 @@ impl Application {
11671192

11681193
#[cfg(all(not(feature = "integration"), not(windows)))]
11691194
pub fn event_stream(&self) -> impl Stream<Item = std::io::Result<TerminalEvent>> + Unpin {
1170-
use termina::Terminal as _;
1195+
use termina::{escape::csi, Terminal as _};
11711196
let reader = self.terminal.backend().terminal().event_reader();
1172-
termina::EventStream::new(reader, |event| !event.is_escape())
1197+
termina::EventStream::new(reader, |event| {
1198+
// Accept either non-escape sequences or theme mode updates.
1199+
!event.is_escape()
1200+
|| matches!(
1201+
event,
1202+
termina::Event::Csi(csi::Csi::Mode(csi::Mode::ReportTheme(_)))
1203+
)
1204+
})
11731205
}
11741206

11751207
#[cfg(all(not(feature = "integration"), windows))]

helix-term/src/config.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::keymap;
22
use crate::keymap::{merge_keys, KeyTrie};
33
use helix_loader::merge_toml_values;
4-
use helix_view::document::Mode;
4+
use helix_view::{document::Mode, theme};
55
use serde::Deserialize;
66
use std::collections::HashMap;
77
use std::fmt::Display;
@@ -11,15 +11,15 @@ use toml::de::Error as TomlError;
1111

1212
#[derive(Debug, Clone, PartialEq)]
1313
pub struct Config {
14-
pub theme: Option<String>,
14+
pub theme: Option<theme::Config>,
1515
pub keys: HashMap<Mode, KeyTrie>,
1616
pub editor: helix_view::editor::Config,
1717
}
1818

1919
#[derive(Debug, Clone, PartialEq, Deserialize)]
2020
#[serde(deny_unknown_fields)]
2121
pub struct ConfigRaw {
22-
pub theme: Option<String>,
22+
pub theme: Option<theme::Config>,
2323
pub keys: Option<HashMap<Mode, KeyTrie>>,
2424
pub editor: Option<toml::Value>,
2525
}

helix-tui/src/backend/crossterm.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,10 @@ where
324324
fn supports_true_color(&self) -> bool {
325325
false
326326
}
327+
328+
fn get_theme_mode(&self) -> Option<helix_view::theme::Mode> {
329+
None
330+
}
327331
}
328332

329333
#[derive(Debug)]

helix-tui/src/backend/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,5 @@ pub trait Backend {
4444
/// Flushes the terminal buffer
4545
fn flush(&mut self) -> Result<(), io::Error>;
4646
fn supports_true_color(&self) -> bool;
47+
fn get_theme_mode(&self) -> Option<helix_view::theme::Mode>;
4748
}

helix-tui/src/backend/termina.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::io::{self, Write as _};
33
use helix_view::{
44
editor::KittyKeyboardProtocolConfig,
55
graphics::{CursorKind, Rect, UnderlineStyle},
6-
theme::{Color, Modifier},
6+
theme::{self, Color, Modifier},
77
};
88
use termina::{
99
escape::{
@@ -52,6 +52,7 @@ struct Capabilities {
5252
synchronized_output: bool,
5353
true_color: bool,
5454
extended_underlines: bool,
55+
theme_mode: Option<theme::Mode>,
5556
}
5657

5758
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
@@ -148,11 +149,13 @@ impl TerminaBackend {
148149
// If we only receive the device attributes then we know it is not.
149150
write!(
150151
terminal,
151-
"{}{}{}{}{}{}",
152+
"{}{}{}{}{}{}{}",
152153
// Synchronized output
153154
Csi::Mode(csi::Mode::QueryDecPrivateMode(csi::DecPrivateMode::Code(
154155
csi::DecPrivateModeCode::SynchronizedOutput
155156
))),
157+
// Mode 2031 theme updates. Query the current theme.
158+
Csi::Mode(csi::Mode::QueryTheme),
156159
// True color and while we're at it, extended underlines:
157160
// <https://github.com/termstandard/colors?tab=readme-ov-file#querying-the-terminal>
158161
Csi::Sgr(csi::Sgr::Background(TEST_COLOR.into())),
@@ -184,6 +187,9 @@ impl TerminaBackend {
184187
})) => {
185188
capabilities.synchronized_output = true;
186189
}
190+
Event::Csi(Csi::Mode(csi::Mode::ReportTheme(mode))) => {
191+
capabilities.theme_mode = Some(mode.into());
192+
}
187193
Event::Dcs(dcs::Dcs::Response {
188194
value: dcs::DcsResponse::GraphicRendition(sgrs),
189195
..
@@ -320,6 +326,11 @@ impl TerminaBackend {
320326
}
321327
}
322328

329+
if self.capabilities.theme_mode.is_some() {
330+
// Enable mode 2031 theme mode notifications:
331+
write!(self.terminal, "{}", decset!(Theme))?;
332+
}
333+
323334
Ok(())
324335
}
325336

@@ -332,6 +343,11 @@ impl TerminaBackend {
332343
)?;
333344
}
334345

346+
if self.capabilities.theme_mode.is_some() {
347+
// Mode 2031 theme notifications.
348+
write!(self.terminal, "{}", decreset!(Theme))?;
349+
}
350+
335351
Ok(())
336352
}
337353

@@ -550,6 +566,10 @@ impl Backend for TerminaBackend {
550566
fn supports_true_color(&self) -> bool {
551567
self.capabilities.true_color
552568
}
569+
570+
fn get_theme_mode(&self) -> Option<theme::Mode> {
571+
self.capabilities.theme_mode
572+
}
553573
}
554574

555575
impl Drop for TerminaBackend {

helix-tui/src/backend/test.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,8 @@ impl Backend for TestBackend {
160160
fn supports_true_color(&self) -> bool {
161161
false
162162
}
163+
164+
fn get_theme_mode(&self) -> Option<helix_view::theme::Mode> {
165+
None
166+
}
163167
}

helix-view/src/theme.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,75 @@ pub static BASE16_DEFAULT_THEME: Lazy<Theme> = Lazy::new(|| Theme {
3535
..Theme::from(BASE16_DEFAULT_THEME_DATA.clone())
3636
});
3737

38+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
39+
pub enum Mode {
40+
Dark,
41+
Light,
42+
}
43+
44+
#[cfg(feature = "term")]
45+
impl From<termina::escape::csi::ThemeMode> for Mode {
46+
fn from(mode: termina::escape::csi::ThemeMode) -> Self {
47+
match mode {
48+
termina::escape::csi::ThemeMode::Dark => Self::Dark,
49+
termina::escape::csi::ThemeMode::Light => Self::Light,
50+
}
51+
}
52+
}
53+
54+
#[derive(Debug, Clone, PartialEq, Eq)]
55+
pub struct Config {
56+
light: String,
57+
dark: String,
58+
/// A theme to choose when the terminal did not declare either light or dark mode.
59+
/// When not specified the dark theme is preferred.
60+
fallback: Option<String>,
61+
}
62+
63+
impl Config {
64+
pub fn choose(&self, preference: Option<Mode>) -> &str {
65+
match preference {
66+
Some(Mode::Light) => &self.light,
67+
Some(Mode::Dark) => &self.dark,
68+
None => self.fallback.as_ref().unwrap_or(&self.dark),
69+
}
70+
}
71+
}
72+
73+
impl<'de> Deserialize<'de> for Config {
74+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
75+
where
76+
D: serde::Deserializer<'de>,
77+
{
78+
#[derive(Deserialize)]
79+
#[serde(untagged, deny_unknown_fields, rename_all = "kebab-case")]
80+
enum InnerConfig {
81+
Constant(String),
82+
Adaptive {
83+
dark: String,
84+
light: String,
85+
fallback: Option<String>,
86+
},
87+
}
88+
89+
let inner = InnerConfig::deserialize(deserializer)?;
90+
91+
let (light, dark, fallback) = match inner {
92+
InnerConfig::Constant(theme) => (theme.clone(), theme.clone(), None),
93+
InnerConfig::Adaptive {
94+
light,
95+
dark,
96+
fallback,
97+
} => (light, dark, fallback),
98+
};
99+
100+
Ok(Self {
101+
light,
102+
dark,
103+
fallback,
104+
})
105+
}
106+
}
38107
#[derive(Clone, Debug)]
39108
pub struct Loader {
40109
/// Theme directories to search from highest to lowest priority

0 commit comments

Comments
 (0)