Compare commits

...

1 Commits

Author SHA1 Message Date
Michael Davis
7b4f90ee79 Support mode 2031 dark/light mode detection 2025-08-31 12:41:10 -04:00
7 changed files with 143 additions and 9 deletions

View File

@@ -2,6 +2,16 @@
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>`. 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>`.
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.
```toml
[theme]
dark = "catppuccin_frappe"
light = "catppuccin_latte"
# Optional. Defaults to the theme set for `dark` if not specified.
# no-preference = "catppuccin_frappe"
```
## Creating a theme ## Creating a theme
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. 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.

View File

@@ -67,6 +67,8 @@ pub struct Application {
signals: Signals, signals: Signals,
jobs: Jobs, jobs: Jobs,
lsp_progress: LspProgressMap, lsp_progress: LspProgressMap,
theme_mode: Option<theme::Mode>,
} }
#[cfg(feature = "integration")] #[cfg(feature = "integration")]
@@ -109,6 +111,7 @@ impl Application {
#[cfg(feature = "integration")] #[cfg(feature = "integration")]
let backend = TestBackend::new(120, 150); let backend = TestBackend::new(120, 150);
let theme_mode = backend.get_theme_mode();
let terminal = Terminal::new(backend)?; let terminal = Terminal::new(backend)?;
let area = terminal.size().expect("couldn't get terminal size"); let area = terminal.size().expect("couldn't get terminal size");
let mut compositor = Compositor::new(area); let mut compositor = Compositor::new(area);
@@ -127,6 +130,7 @@ impl Application {
&mut editor, &mut editor,
&config.load(), &config.load(),
terminal.backend().supports_true_color(), terminal.backend().supports_true_color(),
theme_mode,
); );
let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| { let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| {
@@ -246,6 +250,7 @@ impl Application {
signals, signals,
jobs: Jobs::new(), jobs: Jobs::new(),
lsp_progress: LspProgressMap::new(), lsp_progress: LspProgressMap::new(),
theme_mode,
}; };
Ok(app) Ok(app)
@@ -404,6 +409,7 @@ impl Application {
&mut self.editor, &mut self.editor,
&default_config, &default_config,
self.terminal.backend().supports_true_color(), self.terminal.backend().supports_true_color(),
self.theme_mode,
); );
// Re-parse any open documents with the new language config. // Re-parse any open documents with the new language config.
@@ -437,12 +443,18 @@ impl Application {
} }
/// Load the theme set in configuration /// Load the theme set in configuration
fn load_configured_theme(editor: &mut Editor, config: &Config, terminal_true_color: bool) { fn load_configured_theme(
editor: &mut Editor,
config: &Config,
terminal_true_color: bool,
mode: Option<theme::Mode>,
) {
let true_color = terminal_true_color || config.editor.true_color || crate::true_color(); let true_color = terminal_true_color || config.editor.true_color || crate::true_color();
let theme = config let theme = config
.theme .theme
.as_ref() .as_ref()
.and_then(|theme| { .and_then(|theme_config| {
let theme = theme_config.choose(mode);
editor editor
.theme_loader .theme_loader
.load(theme) .load(theme)
@@ -643,6 +655,8 @@ impl Application {
} }
pub async fn handle_terminal_events(&mut self, event: std::io::Result<termina::Event>) { pub async fn handle_terminal_events(&mut self, event: std::io::Result<termina::Event>) {
use termina::escape::csi;
let mut cx = crate::compositor::Context { let mut cx = crate::compositor::Context {
editor: &mut self.editor, editor: &mut self.editor,
jobs: &mut self.jobs, jobs: &mut self.jobs,
@@ -667,6 +681,15 @@ impl Application {
kind: termina::event::KeyEventKind::Release, kind: termina::event::KeyEventKind::Release,
.. ..
}) => false, }) => false,
termina::Event::Csi(csi::Csi::Mode(csi::Mode::ReportTheme(mode))) => {
Self::load_configured_theme(
&mut self.editor,
&self.config.load(),
self.terminal.backend().supports_true_color(),
Some(mode.into()),
);
true
}
event => self.compositor.handle_event(&event.into(), &mut cx), event => self.compositor.handle_event(&event.into(), &mut cx),
}; };
@@ -1117,9 +1140,16 @@ impl Application {
#[cfg(not(feature = "integration"))] #[cfg(not(feature = "integration"))]
pub fn event_stream(&self) -> impl Stream<Item = std::io::Result<termina::Event>> + Unpin { pub fn event_stream(&self) -> impl Stream<Item = std::io::Result<termina::Event>> + Unpin {
use termina::Terminal as _; use termina::{escape::csi, Terminal as _};
let reader = self.terminal.backend().terminal().event_reader(); let reader = self.terminal.backend().terminal().event_reader();
termina::EventStream::new(reader, |event| !event.is_escape()) termina::EventStream::new(reader, |event| {
// Accept either non-escape sequences or theme mode updates.
!event.is_escape()
|| matches!(
event,
termina::Event::Csi(csi::Csi::Mode(csi::Mode::ReportTheme(_)))
)
})
} }
#[cfg(feature = "integration")] #[cfg(feature = "integration")]

View File

@@ -1,7 +1,7 @@
use crate::keymap; use crate::keymap;
use crate::keymap::{merge_keys, KeyTrie}; use crate::keymap::{merge_keys, KeyTrie};
use helix_loader::merge_toml_values; use helix_loader::merge_toml_values;
use helix_view::document::Mode; use helix_view::{document::Mode, theme};
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt::Display; use std::fmt::Display;
@@ -11,7 +11,7 @@ use toml::de::Error as TomlError;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct Config { pub struct Config {
pub theme: Option<String>, pub theme: Option<theme::Config>,
pub keys: HashMap<Mode, KeyTrie>, pub keys: HashMap<Mode, KeyTrie>,
pub editor: helix_view::editor::Config, pub editor: helix_view::editor::Config,
} }
@@ -19,7 +19,7 @@ pub struct Config {
#[derive(Debug, Clone, PartialEq, Deserialize)] #[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ConfigRaw { pub struct ConfigRaw {
pub theme: Option<String>, pub theme: Option<theme::Config>,
pub keys: Option<HashMap<Mode, KeyTrie>>, pub keys: Option<HashMap<Mode, KeyTrie>>,
pub editor: Option<toml::Value>, pub editor: Option<toml::Value>,
} }

View File

@@ -39,4 +39,5 @@ pub trait Backend {
/// Flushes the terminal buffer /// Flushes the terminal buffer
fn flush(&mut self) -> Result<(), io::Error>; fn flush(&mut self) -> Result<(), io::Error>;
fn supports_true_color(&self) -> bool; fn supports_true_color(&self) -> bool;
fn get_theme_mode(&self) -> Option<helix_view::theme::Mode>;
} }

View File

@@ -2,7 +2,7 @@ use std::io::{self, Write as _};
use helix_view::{ use helix_view::{
graphics::{CursorKind, Rect, UnderlineStyle}, graphics::{CursorKind, Rect, UnderlineStyle},
theme::{Color, Modifier}, theme::{self, Color, Modifier},
}; };
use termina::{ use termina::{
escape::{ escape::{
@@ -67,6 +67,7 @@ struct Capabilities {
synchronized_output: bool, synchronized_output: bool,
true_color: bool, true_color: bool,
extended_underlines: bool, extended_underlines: bool,
theme_mode: Option<theme::Mode>,
} }
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
@@ -154,13 +155,15 @@ impl TerminaBackend {
// If we only receive the device attributes then we know it is not. // If we only receive the device attributes then we know it is not.
write!( write!(
terminal, terminal,
"{}{}{}{}{}{}{}", "{}{}{}{}{}{}{}{}",
// Kitty keyboard // Kitty keyboard
Csi::Keyboard(csi::Keyboard::QueryFlags), Csi::Keyboard(csi::Keyboard::QueryFlags),
// Synchronized output // Synchronized output
Csi::Mode(csi::Mode::QueryDecPrivateMode(csi::DecPrivateMode::Code( Csi::Mode(csi::Mode::QueryDecPrivateMode(csi::DecPrivateMode::Code(
csi::DecPrivateModeCode::SynchronizedOutput csi::DecPrivateModeCode::SynchronizedOutput
))), ))),
// Mode 2031 theme updates. Query the current theme.
Csi::Mode(csi::Mode::QueryTheme),
// True color and while we're at it, extended underlines: // True color and while we're at it, extended underlines:
// <https://github.com/termstandard/colors?tab=readme-ov-file#querying-the-terminal> // <https://github.com/termstandard/colors?tab=readme-ov-file#querying-the-terminal>
Csi::Sgr(csi::Sgr::Background(TEST_COLOR.into())), Csi::Sgr(csi::Sgr::Background(TEST_COLOR.into())),
@@ -192,6 +195,9 @@ impl TerminaBackend {
})) => { })) => {
capabilities.synchronized_output = true; capabilities.synchronized_output = true;
} }
Event::Csi(Csi::Mode(csi::Mode::ReportTheme(mode))) => {
capabilities.theme_mode = Some(mode.into());
}
Event::Dcs(dcs::Dcs::Response { Event::Dcs(dcs::Dcs::Response {
value: dcs::DcsResponse::GraphicRendition(sgrs), value: dcs::DcsResponse::GraphicRendition(sgrs),
.. ..
@@ -317,6 +323,11 @@ impl TerminaBackend {
} }
} }
if self.capabilities.theme_mode.is_some() {
// Enable mode 2031 theme mode notifications:
write!(self.terminal, "{}", decset!(Theme))?;
}
Ok(()) Ok(())
} }
@@ -329,6 +340,11 @@ impl TerminaBackend {
)?; )?;
} }
if self.capabilities.theme_mode.is_some() {
// Mode 2031 theme notifications.
write!(self.terminal, "{}", decreset!(Theme))?;
}
Ok(()) Ok(())
} }
@@ -547,6 +563,10 @@ impl Backend for TerminaBackend {
fn supports_true_color(&self) -> bool { fn supports_true_color(&self) -> bool {
self.capabilities.true_color self.capabilities.true_color
} }
fn get_theme_mode(&self) -> Option<theme::Mode> {
self.capabilities.theme_mode
}
} }
impl Drop for TerminaBackend { impl Drop for TerminaBackend {

View File

@@ -160,4 +160,8 @@ impl Backend for TestBackend {
fn supports_true_color(&self) -> bool { fn supports_true_color(&self) -> bool {
false false
} }
fn get_theme_mode(&self) -> Option<helix_view::theme::Mode> {
None
}
} }

View File

@@ -35,6 +35,75 @@ pub static BASE16_DEFAULT_THEME: Lazy<Theme> = Lazy::new(|| Theme {
..Theme::from(BASE16_DEFAULT_THEME_DATA.clone()) ..Theme::from(BASE16_DEFAULT_THEME_DATA.clone())
}); });
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Mode {
Dark,
Light,
}
#[cfg(feature = "term")]
impl From<termina::escape::csi::ThemeMode> for Mode {
fn from(mode: termina::escape::csi::ThemeMode) -> Self {
match mode {
termina::escape::csi::ThemeMode::Dark => Self::Dark,
termina::escape::csi::ThemeMode::Light => Self::Light,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub light: String,
pub dark: String,
/// A theme to choose when the terminal did not declare either light or dark mode.
/// When not specified the dark theme is preferred.
pub no_preference: Option<String>,
}
impl Config {
pub fn choose(&self, preference: Option<Mode>) -> &str {
match preference {
Some(Mode::Light) => &self.light,
Some(Mode::Dark) => &self.dark,
None => self.no_preference.as_ref().unwrap_or(&self.dark),
}
}
}
impl<'de> Deserialize<'de> for Config {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged, deny_unknown_fields, rename_all = "kebab-case")]
enum InnerConfig {
Constant(String),
Adaptive {
dark: String,
light: String,
no_preference: Option<String>,
},
}
let inner = InnerConfig::deserialize(deserializer)?;
let (light, dark, no_preference) = match inner {
InnerConfig::Constant(theme) => (theme.clone(), theme.clone(), None),
InnerConfig::Adaptive {
light,
dark,
no_preference,
} => (light, dark, no_preference),
};
Ok(Self {
light,
dark,
no_preference,
})
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Loader { pub struct Loader {
/// Theme directories to search from highest to lowest priority /// Theme directories to search from highest to lowest priority