Detect and respond to terminal theme mode (light/dark) updates

This commit is contained in:
Michael Davis 2024-12-28 20:18:42 -05:00
parent 6d07ae4f07
commit 0caa2fe9be
No known key found for this signature in database
10 changed files with 161 additions and 22 deletions

19
Cargo.lock generated
View File

@ -239,11 +239,11 @@ checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
source = "git+https://github.com/the-mikedavis/crossterm?branch=md/theme-mode#dd0ae541da4b54f35eb93a29cd717f0e32d2ed77"
dependencies = [
"bitflags",
"crossterm_winapi",
"document-features",
"filedescriptor",
"futures-core",
"libc",
@ -289,6 +289,15 @@ dependencies = [
"syn",
]
[[package]]
name = "document-features"
version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb6969eaabd2421f8a2775cfd2471a2b634372b4a25d41e3bd647b79912850a0"
dependencies = [
"litrs",
]
[[package]]
name = "dunce"
version = "1.0.5"
@ -1866,6 +1875,12 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704"
[[package]]
name = "litrs"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5"
[[package]]
name = "lock_api"
version = "0.4.12"

View File

@ -43,6 +43,9 @@ nucleo = "0.5.0"
slotmap = "1.0.7"
thiserror = "2.0"
[patch.crates-io]
crossterm = { git = "https://github.com/the-mikedavis/crossterm", branch = "md/theme-mode" }
[workspace.package]
version = "24.7.0"
edition = "2021"

View File

@ -74,6 +74,9 @@ pub struct Application {
signals: Signals,
jobs: Jobs,
lsp_progress: LspProgressMap,
/// The theme mode (light/dark) detected from the terminal, if available.
theme_mode: Option<theme::Mode>,
}
#[cfg(feature = "integration")]
@ -105,15 +108,24 @@ pub fn new(args: Args, config: Config, lang_loader: syntax::Loader) -> Result<Se
use helix_view::editor::Action;
#[cfg(not(feature = "integration"))]
let backend = CrosstermBackend::new(stdout(), &config.editor);
#[cfg(feature = "integration")]
let backend = TestBackend::new(120, 150);
let mut theme_parent_dirs = vec![helix_loader::config_dir()];
theme_parent_dirs.extend(helix_loader::runtime_dirs().iter().cloned());
let theme_loader = std::sync::Arc::new(theme::Loader::new(&theme_parent_dirs));
let theme_mode = backend.get_theme_mode();
let 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(theme_mode);
theme_loader
.load(theme)
.map_err(|e| {
@ -127,12 +139,6 @@ pub fn new(args: Args, config: Config, lang_loader: syntax::Loader) -> Result<Se
let syn_loader = Arc::new(ArcSwap::from_pointee(lang_loader));
#[cfg(not(feature = "integration"))]
let backend = CrosstermBackend::new(stdout(), &config.editor);
#[cfg(feature = "integration")]
let backend = TestBackend::new(120, 150);
let terminal = Terminal::new(backend)?;
let area = terminal.size().expect("couldn't get terminal size");
let mut compositor = Compositor::new(area);
@ -151,7 +157,10 @@ pub fn new(args: Args, config: Config, lang_loader: syntax::Loader) -> Result<Se
let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| {
&config.keys
}));
let editor_view = Box::new(ui::EditorView::new(Keymaps::new(keys)));
let editor_view = Box::new(ui::EditorView::new(
Keymaps::new(keys),
Map::new(Arc::clone(&config), |config: &Config| &config.theme),
));
compositor.push(editor_view);
if args.load_tutor {
@ -267,6 +276,7 @@ pub fn new(args: Args, config: Config, lang_loader: syntax::Loader) -> Result<Se
signals,
jobs: Jobs::new(),
lsp_progress: LspProgressMap::new(),
theme_mode,
};
Ok(app)
@ -434,7 +444,9 @@ fn refresh_theme(&mut self, config: &Config) -> Result<(), Error> {
let theme = config
.theme
.as_ref()
.and_then(|theme| {
.and_then(|theme_config| {
let theme = theme_config.choose(self.theme_mode);
self.theme_loader
.load(theme)
.map_err(|e| {

View File

@ -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 @@
#[derive(Debug, Clone, PartialEq)]
pub struct Config {
pub theme: Option<String>,
pub theme: Option<theme::Config>,
pub keys: HashMap<Mode, KeyTrie>,
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<String>,
pub theme: Option<theme::Config>,
pub keys: Option<HashMap<Mode, KeyTrie>>,
pub editor: Option<toml::Value>,
}

View File

@ -13,6 +13,7 @@
},
};
use arc_swap::access::DynAccess;
use helix_core::{
diagnostic::NumberOrString,
graphemes::{next_grapheme_boundary, prev_grapheme_boundary},
@ -29,7 +30,7 @@
graphics::{Color, CursorKind, Modifier, Rect, Style},
input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind},
keyboard::{KeyCode, KeyModifiers},
Document, Editor, Theme, View,
theme, Document, Editor, Theme, View,
};
use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc};
@ -37,6 +38,7 @@
pub struct EditorView {
pub keymaps: Keymaps,
theme_config: Box<dyn DynAccess<Option<theme::Config>>>,
on_next_key: Option<(OnKeyCallback, OnKeyCallbackKind)>,
pseudo_pending: Vec<KeyEvent>,
pub(crate) last_insert: (commands::MappableCommand, Vec<InsertEvent>),
@ -58,9 +60,13 @@ pub enum InsertEvent {
}
impl EditorView {
pub fn new(keymaps: Keymaps) -> Self {
pub fn new(
keymaps: Keymaps,
theme_config: impl DynAccess<Option<theme::Config>> + 'static,
) -> Self {
Self {
keymaps,
theme_config: Box::new(theme_config),
on_next_key: None,
pseudo_pending: Vec::new(),
last_insert: (commands::MappableCommand::normal_mode, Vec::new()),
@ -1528,6 +1534,18 @@ fn handle_event(
self.terminal_focused = false;
EventResult::Consumed(None)
}
Event::ThemeModeChanged(theme_mode) => {
if let Some(theme_config) = self.theme_config.load().as_ref() {
let theme_name = theme_config.choose(Some(*theme_mode));
match context.editor.theme_loader.load(theme_name) {
Ok(theme) => context.editor.set_theme(theme),
Err(err) => {
log::warn!("failed to load theme `{}` - {}", theme_name, err);
}
}
}
EventResult::Consumed(None)
}
}
}

View File

@ -2,9 +2,9 @@
use crossterm::{
cursor::{Hide, MoveTo, SetCursorStyle, Show},
event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags,
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
DisableBracketedPaste, DisableColorSchemeUpdates, DisableFocusChange, DisableMouseCapture,
EnableBracketedPaste, EnableColorSchemeUpdates, EnableFocusChange, EnableMouseCapture,
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
},
execute, queue,
style::{
@ -17,6 +17,7 @@
use helix_view::{
editor::Config as EditorConfig,
graphics::{Color, CursorKind, Modifier, Rect, UnderlineStyle},
theme,
};
use once_cell::sync::OnceCell;
use std::{
@ -161,7 +162,8 @@ fn claim(&mut self, config: Config) -> io::Result<()> {
execute!(
self.buffer,
terminal::EnterAlternateScreen,
EnableFocusChange
EnableFocusChange,
EnableColorSchemeUpdates,
)?;
match execute!(self.buffer, EnableBracketedPaste,) {
Err(err) if err.kind() == io::ErrorKind::Unsupported => {
@ -217,6 +219,7 @@ fn restore(&mut self, config: Config) -> io::Result<()> {
execute!(
self.buffer,
DisableFocusChange,
DisableColorSchemeUpdates,
terminal::LeaveAlternateScreen
)?;
terminal::disable_raw_mode()
@ -232,7 +235,12 @@ fn force_restore() -> io::Result<()> {
let _ = execute!(stdout, DisableMouseCapture);
let _ = execute!(stdout, PopKeyboardEnhancementFlags);
let _ = execute!(stdout, DisableBracketedPaste);
execute!(stdout, DisableFocusChange, terminal::LeaveAlternateScreen)?;
execute!(
stdout,
DisableFocusChange,
DisableColorSchemeUpdates,
terminal::LeaveAlternateScreen
)?;
terminal::disable_raw_mode()
}
@ -338,6 +346,29 @@ fn size(&self) -> io::Result<Rect> {
fn flush(&mut self) -> io::Result<()> {
self.buffer.flush()
}
fn get_theme_mode(&self) -> Option<theme::Mode> {
use std::time::Instant;
let start = Instant::now();
let theme_mode = crossterm::terminal::query_terminal_theme_mode()
.ok()
.flatten()
.map(|theme_mode| match theme_mode {
crossterm::event::ThemeMode::Light => theme::Mode::Light,
crossterm::event::ThemeMode::Dark => theme::Mode::Dark,
});
let elapsed = Instant::now().duration_since(start).as_millis();
if theme_mode.is_some() {
log::debug!("detected terminal theme mode in {}ms", elapsed);
} else {
log::debug!(
"failed to detect terminal theme mode (checked in {}ms)",
elapsed
);
}
theme_mode
}
}
#[derive(Debug)]

View File

@ -2,7 +2,10 @@
use crate::{buffer::Cell, terminal::Config};
use helix_view::graphics::{CursorKind, Rect};
use helix_view::{
graphics::{CursorKind, Rect},
theme,
};
#[cfg(feature = "crossterm")]
mod crossterm;
@ -27,4 +30,5 @@ fn draw<'a, I>(&mut self, content: I) -> Result<(), io::Error>
fn clear(&mut self) -> Result<(), io::Error>;
fn size(&self) -> Result<Rect, io::Error>;
fn flush(&mut self) -> Result<(), io::Error>;
fn get_theme_mode(&self) -> Option<theme::Mode>;
}

View File

@ -164,4 +164,8 @@ fn size(&self) -> Result<Rect, io::Error> {
fn flush(&mut self) -> Result<(), io::Error> {
Ok(())
}
fn get_theme_mode(&self) -> Option<helix_view::theme::Mode> {
None
}
}

View File

@ -5,6 +5,7 @@
use std::fmt;
pub use crate::keyboard::{KeyCode, KeyModifiers, MediaKeyCode, ModifierKeyCode};
use crate::theme;
#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Hash)]
pub enum Event {
@ -14,6 +15,7 @@ pub enum Event {
Mouse(MouseEvent),
Paste(String),
Resize(u16, u16),
ThemeModeChanged(theme::Mode),
IdleTimeout,
}
@ -468,6 +470,12 @@ fn from(event: crossterm::event::Event) -> Self {
crossterm::event::Event::FocusGained => Self::FocusGained,
crossterm::event::Event::FocusLost => Self::FocusLost,
crossterm::event::Event::Paste(s) => Self::Paste(s),
crossterm::event::Event::ThemeModeChanged(theme_mode) => {
Self::ThemeModeChanged(match theme_mode {
crossterm::event::ThemeMode::Light => theme::Mode::Light,
crossterm::event::ThemeMode::Dark => theme::Mode::Dark,
})
}
}
}
}

View File

@ -35,6 +35,50 @@
..Theme::from(BASE16_DEFAULT_THEME_DATA.clone())
});
#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)]
pub enum Mode {
Light,
Dark,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
light: String,
dark: String,
}
impl Config {
pub fn choose(&self, preference: Option<Mode>) -> &str {
match preference {
Some(Mode::Light) => &self.light,
Some(Mode::Dark) | None => &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)]
enum InnerConfig {
Constant(String),
Adaptive { dark: String, light: String },
}
let inner = InnerConfig::deserialize(deserializer)?;
let (light, dark) = match inner {
InnerConfig::Constant(theme) => (theme.clone(), theme),
InnerConfig::Adaptive { light, dark } => (light, dark),
};
Ok(Self { light, dark })
}
}
#[derive(Clone, Debug)]
pub struct Loader {
/// Theme directories to search from highest to lowest priority