diff --git a/book/src/themes.md b/book/src/themes.md index 8140120bc..353a46844 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -2,6 +2,17 @@ To use a theme add `theme = ""` to the top of your [`config.toml`](./configuration.md) file, or select it during runtime using `:theme `. +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. Used if the terminal doesn't declare a preference. +## Defaults to the theme set for `dark` if not specified. +# fallback = "catppuccin_frappe" +``` + ## 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. diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 9ee02a536..cf8ab233e 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -77,6 +77,8 @@ pub struct Application { signals: Signals, jobs: Jobs, lsp_progress: LspProgressMap, + + theme_mode: Option, } #[cfg(feature = "integration")] @@ -121,6 +123,7 @@ impl Application { #[cfg(feature = "integration")] let backend = TestBackend::new(120, 150); + let theme_mode = backend.get_theme_mode(); let terminal = Terminal::new(backend)?; let area = terminal.size().expect("couldn't get terminal size"); let mut compositor = Compositor::new(area); @@ -139,6 +142,7 @@ impl Application { &mut editor, &config.load(), terminal.backend().supports_true_color(), + theme_mode, ); let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| { @@ -258,6 +262,7 @@ impl Application { signals, jobs: Jobs::new(), lsp_progress: LspProgressMap::new(), + theme_mode, }; Ok(app) @@ -416,6 +421,7 @@ impl Application { &mut self.editor, &default_config, self.terminal.backend().supports_true_color(), + self.theme_mode, ); // Re-parse any open documents with the new language config. @@ -449,12 +455,18 @@ impl Application { } /// 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, + ) { let true_color = terminal_true_color || config.editor.true_color || crate::true_color(); let theme = config .theme .as_ref() - .and_then(|theme| { + .and_then(|theme_config| { + let theme = theme_config.choose(mode); editor .theme_loader .load(theme) @@ -672,6 +684,9 @@ impl Application { } pub async fn handle_terminal_events(&mut self, event: std::io::Result) { + #[cfg(not(windows))] + use termina::escape::csi; + let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, @@ -698,6 +713,16 @@ impl Application { kind: termina::event::KeyEventKind::Release, .. }) => false, + #[cfg(not(windows))] + 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 + } #[cfg(windows)] TerminalEvent::Resize(width, height) => { self.terminal @@ -1167,9 +1192,16 @@ impl Application { #[cfg(all(not(feature = "integration"), not(windows)))] pub fn event_stream(&self) -> impl Stream> + Unpin { - use termina::Terminal as _; + use termina::{escape::csi, Terminal as _}; 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(all(not(feature = "integration"), windows))] diff --git a/helix-term/src/config.rs b/helix-term/src/config.rs index bcba8d8e1..dd0519841 100644 --- a/helix-term/src/config.rs +++ b/helix-term/src/config.rs @@ -1,7 +1,7 @@ use crate::keymap; use crate::keymap::{merge_keys, KeyTrie}; use helix_loader::merge_toml_values; -use helix_view::document::Mode; +use helix_view::{document::Mode, theme}; use serde::Deserialize; use std::collections::HashMap; use std::fmt::Display; @@ -11,7 +11,7 @@ use toml::de::Error as TomlError; #[derive(Debug, Clone, PartialEq)] pub struct Config { - pub theme: Option, + pub theme: Option, pub keys: HashMap, pub editor: helix_view::editor::Config, } @@ -19,7 +19,7 @@ pub struct Config { #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(deny_unknown_fields)] pub struct ConfigRaw { - pub theme: Option, + pub theme: Option, pub keys: Option>, pub editor: Option, } diff --git a/helix-tui/src/backend/crossterm.rs b/helix-tui/src/backend/crossterm.rs index 943821b91..3b53c21f3 100644 --- a/helix-tui/src/backend/crossterm.rs +++ b/helix-tui/src/backend/crossterm.rs @@ -324,6 +324,10 @@ where fn supports_true_color(&self) -> bool { false } + + fn get_theme_mode(&self) -> Option { + None + } } #[derive(Debug)] diff --git a/helix-tui/src/backend/mod.rs b/helix-tui/src/backend/mod.rs index 37160d4fa..368a1b660 100644 --- a/helix-tui/src/backend/mod.rs +++ b/helix-tui/src/backend/mod.rs @@ -44,4 +44,5 @@ pub trait Backend { /// Flushes the terminal buffer fn flush(&mut self) -> Result<(), io::Error>; fn supports_true_color(&self) -> bool; + fn get_theme_mode(&self) -> Option; } diff --git a/helix-tui/src/backend/termina.rs b/helix-tui/src/backend/termina.rs index 529121619..ad1c7c683 100644 --- a/helix-tui/src/backend/termina.rs +++ b/helix-tui/src/backend/termina.rs @@ -3,7 +3,7 @@ use std::io::{self, Write as _}; use helix_view::{ editor::KittyKeyboardProtocolConfig, graphics::{CursorKind, Rect, UnderlineStyle}, - theme::{Color, Modifier}, + theme::{self, Color, Modifier}, }; use termina::{ escape::{ @@ -52,6 +52,7 @@ struct Capabilities { synchronized_output: bool, true_color: bool, extended_underlines: bool, + theme_mode: Option, } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] @@ -148,11 +149,13 @@ impl TerminaBackend { // If we only receive the device attributes then we know it is not. write!( terminal, - "{}{}{}{}{}{}", + "{}{}{}{}{}{}{}", // Synchronized output Csi::Mode(csi::Mode::QueryDecPrivateMode(csi::DecPrivateMode::Code( 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: // Csi::Sgr(csi::Sgr::Background(TEST_COLOR.into())), @@ -184,6 +187,9 @@ impl TerminaBackend { })) => { capabilities.synchronized_output = true; } + Event::Csi(Csi::Mode(csi::Mode::ReportTheme(mode))) => { + capabilities.theme_mode = Some(mode.into()); + } Event::Dcs(dcs::Dcs::Response { value: dcs::DcsResponse::GraphicRendition(sgrs), .. @@ -320,6 +326,11 @@ impl TerminaBackend { } } + if self.capabilities.theme_mode.is_some() { + // Enable mode 2031 theme mode notifications: + write!(self.terminal, "{}", decset!(Theme))?; + } + Ok(()) } @@ -332,6 +343,11 @@ impl TerminaBackend { )?; } + if self.capabilities.theme_mode.is_some() { + // Mode 2031 theme notifications. + write!(self.terminal, "{}", decreset!(Theme))?; + } + Ok(()) } @@ -550,6 +566,10 @@ impl Backend for TerminaBackend { fn supports_true_color(&self) -> bool { self.capabilities.true_color } + + fn get_theme_mode(&self) -> Option { + self.capabilities.theme_mode + } } impl Drop for TerminaBackend { diff --git a/helix-tui/src/backend/test.rs b/helix-tui/src/backend/test.rs index 37b5ff5ce..b048cefcd 100644 --- a/helix-tui/src/backend/test.rs +++ b/helix-tui/src/backend/test.rs @@ -160,4 +160,8 @@ impl Backend for TestBackend { fn supports_true_color(&self) -> bool { false } + + fn get_theme_mode(&self) -> Option { + None + } } diff --git a/helix-view/src/theme.rs b/helix-view/src/theme.rs index e2e109328..173a40f3f 100644 --- a/helix-view/src/theme.rs +++ b/helix-view/src/theme.rs @@ -35,6 +35,75 @@ pub static BASE16_DEFAULT_THEME: Lazy = Lazy::new(|| Theme { ..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 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 { + light: String, + 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. + fallback: Option, +} + +impl Config { + pub fn choose(&self, preference: Option) -> &str { + match preference { + Some(Mode::Light) => &self.light, + Some(Mode::Dark) => &self.dark, + None => self.fallback.as_ref().unwrap_or(&self.dark), + } + } +} + +impl<'de> Deserialize<'de> for Config { + fn deserialize(deserializer: D) -> Result + 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, + fallback: Option, + }, + } + + let inner = InnerConfig::deserialize(deserializer)?; + + let (light, dark, fallback) = match inner { + InnerConfig::Constant(theme) => (theme.clone(), theme.clone(), None), + InnerConfig::Adaptive { + light, + dark, + fallback, + } => (light, dark, fallback), + }; + + Ok(Self { + light, + dark, + fallback, + }) + } +} #[derive(Clone, Debug)] pub struct Loader { /// Theme directories to search from highest to lowest priority