mirror of
https://github.com/zellij-org/zellij.git
synced 2026-10-12 02:27:24 +00:00
fix: allow setting an explicit theme hue rather than guessing (#5536)
* fix: allow setting an explicit theme hue rather than guessing * add pr
This commit is contained in:
@@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
* feat: allow opting-out of scroll mode sync (https://github.com/zellij-org/zellij/pull/5532)
|
||||
* fix: save space for title row in fixed-layout panes in titles only mode (https://github.com/zellij-org/zellij/pull/5533)
|
||||
* fix: properly fire visibility event to plugins when their tab/floating-layer is hidden/shown (https://github.com/zellij-org/zellij/pull/5518 and https://github.com/zellij-org/zellij/pull/5534)
|
||||
* feat: allow setting explicit_theme_hue (light/dark) (https://github.com/zellij-org/zellij/pull/5536)
|
||||
|
||||
## [0.45.0] - 2026-08-20
|
||||
* feat: allow tabs to have different sizes if clients aren't focused on the same one (https://github.com/zellij-org/zellij/pull/5133)
|
||||
|
||||
@@ -454,6 +454,7 @@ impl SessionMetaData {
|
||||
.unwrap_or_else(|| default_palette().into()),
|
||||
host_theme_dark,
|
||||
host_theme_light,
|
||||
explicit_theme_hue: new_config.options.explicit_theme_hue,
|
||||
simplified_ui: new_config.options.simplified_ui.unwrap_or(false),
|
||||
default_shell: new_config.options.default_shell,
|
||||
pane_frame_style,
|
||||
|
||||
@@ -762,6 +762,9 @@ pub(crate) fn plugin_thread_main(
|
||||
if events.contains(&EventType::InitialKeybinds) {
|
||||
wasm_bridge.send_initial_keybinds_to_plugin(plugin_id, client_id);
|
||||
}
|
||||
if events.contains(&EventType::HostTerminalThemeChanged) {
|
||||
wasm_bridge.send_host_terminal_theme_mode_to_plugin(plugin_id, client_id);
|
||||
}
|
||||
if events.contains(&EventType::HintText) {
|
||||
let _ = bus
|
||||
.senders
|
||||
|
||||
@@ -23,8 +23,9 @@ use url::Url;
|
||||
use wasmi::{Engine, Module};
|
||||
use zellij_utils::consts::{ZELLIJ_CACHE_DIR, ZELLIJ_SESSION_CACHE_DIR, ZELLIJ_TMP_DIR};
|
||||
use zellij_utils::data::{
|
||||
FloatingPaneCoordinates, InputMode, KeybindsVec, LayoutInfo, LayoutWithError, PaneContents,
|
||||
PaneRenderReport, PermissionStatus, PermissionType, PipeMessage, PipeSource,
|
||||
FloatingPaneCoordinates, HostTerminalThemeMode, InputMode, KeybindsVec, LayoutInfo,
|
||||
LayoutWithError, PaneContents, PaneRenderReport, PermissionStatus, PermissionType, PipeMessage,
|
||||
PipeSource,
|
||||
};
|
||||
use zellij_utils::downloader::Downloader;
|
||||
use zellij_utils::input::keybinds::Keybinds;
|
||||
@@ -200,6 +201,7 @@ pub struct WasmBridge {
|
||||
base_modes: HashMap<ClientId, InputMode>,
|
||||
downloader: Downloader,
|
||||
previous_pane_render_report: Option<PaneRenderReport>,
|
||||
last_host_terminal_theme_mode: Option<HostTerminalThemeMode>,
|
||||
pub last_session_save_time: Arc<Mutex<Option<u64>>>, // milliseconds since UNIX epoch
|
||||
}
|
||||
|
||||
@@ -262,6 +264,7 @@ impl WasmBridge {
|
||||
base_modes: HashMap::new(),
|
||||
downloader,
|
||||
previous_pane_render_report: None,
|
||||
last_host_terminal_theme_mode: None,
|
||||
last_session_save_time: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
@@ -881,6 +884,13 @@ impl WasmBridge {
|
||||
mut updates: Vec<(Option<PluginId>, Option<ClientId>, Event)>,
|
||||
shutdown_sender: Sender<()>,
|
||||
) -> Result<()> {
|
||||
for (plugin_id, client_id, event) in updates.iter() {
|
||||
if plugin_id.is_none() && client_id.is_none() {
|
||||
if let Event::HostTerminalThemeChanged(mode) = event {
|
||||
self.last_host_terminal_theme_mode = Some(*mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
let plugins_to_update: Vec<(
|
||||
PluginId,
|
||||
ClientId,
|
||||
@@ -1354,6 +1364,21 @@ impl WasmBridge {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_host_terminal_theme_mode_to_plugin(
|
||||
&self,
|
||||
plugin_id: PluginId,
|
||||
client_id: ClientId,
|
||||
) {
|
||||
let Some(mode) = self.last_host_terminal_theme_mode else {
|
||||
return;
|
||||
};
|
||||
let _ = self.senders.send_to_plugin(PluginInstruction::Update(vec![(
|
||||
Some(plugin_id),
|
||||
Some(client_id),
|
||||
Event::HostTerminalThemeChanged(mode),
|
||||
)]));
|
||||
}
|
||||
|
||||
fn send_keybinds_payload_to_plugin(
|
||||
&self,
|
||||
plugin_id: PluginId,
|
||||
|
||||
+85
-10
@@ -802,6 +802,7 @@ pub enum ScreenInstruction {
|
||||
host_theme_dark: Option<Styling>,
|
||||
/// Resolved styling for `theme_light`. See `host_theme_dark`.
|
||||
host_theme_light: Option<Styling>,
|
||||
explicit_theme_hue: Option<ThemeHue>,
|
||||
simplified_ui: bool,
|
||||
default_shell: Option<PathBuf>,
|
||||
pane_frame_style: PaneFrameStyle,
|
||||
@@ -1617,6 +1618,9 @@ pub(crate) struct Screen {
|
||||
host_descend_keys: Vec<KeyWithModifier>,
|
||||
host_descended: bool,
|
||||
host_terminal_theme_mode: Option<HostTerminalThemeMode>,
|
||||
last_host_reported_theme_mode: Option<HostTerminalThemeMode>,
|
||||
theme_policy: ThemePolicy,
|
||||
configured_explicit_theme_hue: Option<ThemeHue>,
|
||||
host_theme_dark_styling: Option<Styling>,
|
||||
host_theme_light_styling: Option<Styling>,
|
||||
nested_session_handling: NestedSessionHandling,
|
||||
@@ -1629,6 +1633,12 @@ pub(crate) struct Screen {
|
||||
client_host_terminal_env: HashMap<ClientId, BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ThemePolicy {
|
||||
Auto,
|
||||
Pinned(HostTerminalThemeMode),
|
||||
}
|
||||
|
||||
/// A pending forward waiting to be dispatched once the current in-flight
|
||||
/// forward's barrier reply (or timeout) arrives.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -1815,6 +1825,9 @@ impl Screen {
|
||||
host_descend_keys: vec![],
|
||||
host_descended: false,
|
||||
host_terminal_theme_mode: None,
|
||||
last_host_reported_theme_mode: None,
|
||||
theme_policy: ThemePolicy::Auto,
|
||||
configured_explicit_theme_hue: None,
|
||||
host_theme_dark_styling: None,
|
||||
host_theme_light_styling: None,
|
||||
nested_session_handling,
|
||||
@@ -6828,6 +6841,7 @@ impl Screen {
|
||||
self.arrow_fonts = should_support_arrow_fonts;
|
||||
|
||||
// global configuration
|
||||
self.style.colors = theme;
|
||||
self.default_mode_info.update_theme(theme);
|
||||
self.default_mode_info
|
||||
.update_rounded_corners(rounded_corners);
|
||||
@@ -6919,16 +6933,14 @@ impl Screen {
|
||||
self.broadcast_nested_shortcuts();
|
||||
Ok(())
|
||||
}
|
||||
/// Apply a host-reported color-palette theme mode (CSI 2031 / DSR 997).
|
||||
///
|
||||
/// This is the Phase 2 entry-point: it
|
||||
/// 1. de-duplicates against the last-known mode,
|
||||
/// 2. swaps the active palette to the configured `theme_dark` / `theme_light`
|
||||
/// (when both are configured) by reusing the reconfigure propagation,
|
||||
/// 3. fans out an `Event::HostTerminalThemeChanged` plugin event,
|
||||
/// 4. forwards a `CSI ?997;{1|2}n` DSR onto the pty of every terminal pane
|
||||
/// whose app opted in via `CSI ? 2031 h`.
|
||||
pub fn update_host_terminal_theme_mode(&mut self, mode: HostTerminalThemeMode) -> Result<()> {
|
||||
self.last_host_reported_theme_mode = Some(mode);
|
||||
if matches!(self.theme_policy, ThemePolicy::Pinned(_)) {
|
||||
return Ok(());
|
||||
}
|
||||
self.apply_theme_mode(mode)
|
||||
}
|
||||
fn apply_theme_mode(&mut self, mode: HostTerminalThemeMode) -> Result<()> {
|
||||
let err_context = || "Failed to update host terminal theme mode".to_string();
|
||||
|
||||
// dedupe
|
||||
@@ -6950,6 +6962,7 @@ impl Screen {
|
||||
self.host_theme_dark_styling.is_some() && self.host_theme_light_styling.is_some();
|
||||
if auto_switch_enabled {
|
||||
if let Some(theme) = resolved {
|
||||
self.style.colors = theme;
|
||||
self.default_mode_info.update_theme(theme);
|
||||
for tab in self.tabs.values_mut() {
|
||||
tab.update_theme(theme);
|
||||
@@ -7047,7 +7060,51 @@ impl Screen {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
self.update_host_terminal_theme_mode(mode)
|
||||
self.theme_policy = ThemePolicy::Pinned(mode);
|
||||
self.apply_theme_mode(mode)
|
||||
}
|
||||
fn resolve_default_theme_mode(&mut self) -> Result<()> {
|
||||
if self.host_terminal_theme_mode.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
if !matches!(self.theme_policy, ThemePolicy::Auto) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.host_theme_dark_styling.is_some() && self.host_theme_light_styling.is_some() {
|
||||
self.apply_theme_mode(HostTerminalThemeMode::Dark)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn reapply_effective_theme_mode(&mut self) -> Result<()> {
|
||||
let known_mode = match self.theme_policy {
|
||||
ThemePolicy::Pinned(mode) => Some(mode),
|
||||
ThemePolicy::Auto => self.host_terminal_theme_mode,
|
||||
};
|
||||
if let Some(mode) = known_mode {
|
||||
self.host_terminal_theme_mode = None;
|
||||
self.apply_theme_mode(mode)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn apply_configured_explicit_theme_hue(
|
||||
&mut self,
|
||||
explicit_theme_hue: Option<ThemeHue>,
|
||||
) -> Result<()> {
|
||||
self.configured_explicit_theme_hue = explicit_theme_hue;
|
||||
match explicit_theme_hue {
|
||||
Some(hue) => {
|
||||
let mode = HostTerminalThemeMode::from(hue);
|
||||
self.theme_policy = ThemePolicy::Pinned(mode);
|
||||
self.apply_theme_mode(mode)
|
||||
},
|
||||
None => {
|
||||
self.theme_policy = ThemePolicy::Auto;
|
||||
match self.last_host_reported_theme_mode {
|
||||
Some(mode) => self.apply_theme_mode(mode),
|
||||
None => Ok(()),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
pub fn toggle_pane_pinned(&mut self, client_id: ClientId) {
|
||||
active_tab_and_connected_client_id!(
|
||||
@@ -7975,6 +8032,8 @@ pub(crate) fn screen_thread_main(
|
||||
);
|
||||
}
|
||||
|
||||
let explicit_theme_hue = config.options.explicit_theme_hue;
|
||||
|
||||
let config_options = config.options;
|
||||
let host_notification_protocol = config_options
|
||||
.host_notification_protocol
|
||||
@@ -8105,6 +8164,13 @@ pub(crate) fn screen_thread_main(
|
||||
screen.host_theme_light_styling = host_theme_light_styling;
|
||||
screen.paste_buffer_read_enabled = dangerously_enable_paste_buffer_read;
|
||||
screen.set_host_notification_protocol(host_notification_protocol);
|
||||
if explicit_theme_hue.is_some() {
|
||||
screen
|
||||
.apply_configured_explicit_theme_hue(explicit_theme_hue)
|
||||
.non_fatal();
|
||||
} else {
|
||||
screen.resolve_default_theme_mode().non_fatal();
|
||||
}
|
||||
|
||||
let mut pending_tab_ids: HashSet<usize> = HashSet::new();
|
||||
let mut pending_tab_switches: HashSet<(usize, ClientId)> = HashSet::new(); // usize is the
|
||||
@@ -11465,6 +11531,7 @@ pub(crate) fn screen_thread_main(
|
||||
theme,
|
||||
host_theme_dark,
|
||||
host_theme_light,
|
||||
explicit_theme_hue,
|
||||
simplified_ui,
|
||||
default_shell,
|
||||
pane_frame_style,
|
||||
@@ -11526,6 +11593,14 @@ pub(crate) fn screen_thread_main(
|
||||
client_id,
|
||||
)
|
||||
.non_fatal();
|
||||
if explicit_theme_hue != screen.configured_explicit_theme_hue {
|
||||
screen
|
||||
.apply_configured_explicit_theme_hue(explicit_theme_hue)
|
||||
.non_fatal();
|
||||
} else {
|
||||
screen.reapply_effective_theme_mode().non_fatal();
|
||||
}
|
||||
screen.resolve_default_theme_mode().non_fatal();
|
||||
},
|
||||
ScreenInstruction::RerunCommandPane(terminal_pane_id, completion_tx) => {
|
||||
screen.rerun_command_pane_with_id(terminal_pane_id, completion_tx)
|
||||
|
||||
@@ -9994,6 +9994,252 @@ fn color_palette_mode_query_skips_plugin_panes() {
|
||||
);
|
||||
}
|
||||
|
||||
fn styling_with_background(color: (u8, u8, u8)) -> zellij_utils::data::Styling {
|
||||
let mut styling = zellij_utils::data::Styling::default();
|
||||
styling.text_unselected.background = zellij_utils::data::PaletteColor::Rgb(color);
|
||||
styling
|
||||
}
|
||||
|
||||
const TEST_DARK_BG: (u8, u8, u8) = (17, 17, 17);
|
||||
const TEST_LIGHT_BG: (u8, u8, u8) = (238, 238, 238);
|
||||
|
||||
fn create_new_screen_with_dark_and_light_themes(size: Size) -> (Screen, ThemeCapture) {
|
||||
let (mut screen, capture) = create_new_screen_with_theme_capture(size);
|
||||
screen.host_theme_dark_styling = Some(styling_with_background(TEST_DARK_BG));
|
||||
screen.host_theme_light_styling = Some(styling_with_background(TEST_LIGHT_BG));
|
||||
(screen, capture)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_theme_hue_resolves_the_session_appearance() {
|
||||
let size = Size { cols: 80, rows: 20 };
|
||||
let (mut screen, capture) = create_new_screen_with_dark_and_light_themes(size);
|
||||
|
||||
screen
|
||||
.apply_configured_explicit_theme_hue(Some(zellij_utils::data::ThemeHue::Light))
|
||||
.expect("explicit hue applied");
|
||||
|
||||
assert_eq!(
|
||||
screen.host_terminal_theme_mode,
|
||||
Some(zellij_utils::data::HostTerminalThemeMode::Light),
|
||||
"an explicit hue must become the session's effective mode"
|
||||
);
|
||||
assert_eq!(
|
||||
screen.style.colors.text_unselected.background,
|
||||
zellij_utils::data::PaletteColor::Rgb(TEST_LIGHT_BG),
|
||||
"Screen's own style must track the swap so later panes inherit it"
|
||||
);
|
||||
let events = capture.drain_plugin_events();
|
||||
assert!(
|
||||
events.iter().any(|e| matches!(
|
||||
e,
|
||||
Event::HostTerminalThemeChanged(zellij_utils::data::HostTerminalThemeMode::Light)
|
||||
)),
|
||||
"plugins must learn the resolved mode, got: {:?}",
|
||||
events
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_and_light_themes_without_an_explicit_hue_default_to_dark() {
|
||||
let size = Size { cols: 80, rows: 20 };
|
||||
let (mut screen, capture) = create_new_screen_with_dark_and_light_themes(size);
|
||||
|
||||
screen
|
||||
.resolve_default_theme_mode()
|
||||
.expect("default resolved");
|
||||
|
||||
assert_eq!(
|
||||
screen.host_terminal_theme_mode,
|
||||
Some(zellij_utils::data::HostTerminalThemeMode::Dark),
|
||||
);
|
||||
assert_eq!(
|
||||
screen.style.colors.text_unselected.background,
|
||||
zellij_utils::data::PaletteColor::Rgb(TEST_DARK_BG),
|
||||
"a configured dark/light pair must resolve to the dark theme rather than \
|
||||
to the unrelated static theme"
|
||||
);
|
||||
assert!(
|
||||
capture.drain_plugin_events().iter().any(|e| matches!(
|
||||
e,
|
||||
Event::HostTerminalThemeChanged(zellij_utils::data::HostTerminalThemeMode::Dark)
|
||||
)),
|
||||
"the resolved mode is real state and must reach plugins"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_dark_mode_still_yields_to_the_host_terminal() {
|
||||
let size = Size { cols: 80, rows: 20 };
|
||||
let (mut screen, _capture) = create_new_screen_with_dark_and_light_themes(size);
|
||||
screen
|
||||
.resolve_default_theme_mode()
|
||||
.expect("default resolved");
|
||||
|
||||
screen
|
||||
.update_host_terminal_theme_mode(zellij_utils::data::HostTerminalThemeMode::Light)
|
||||
.expect("host report applied");
|
||||
|
||||
assert_eq!(
|
||||
screen.host_terminal_theme_mode,
|
||||
Some(zellij_utils::data::HostTerminalThemeMode::Light),
|
||||
"the default is not a pin - the host terminal remains authoritative"
|
||||
);
|
||||
assert_eq!(
|
||||
screen.style.colors.text_unselected.background,
|
||||
zellij_utils::data::PaletteColor::Rgb(TEST_LIGHT_BG),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_default_mode_without_both_themes_configured() {
|
||||
let size = Size { cols: 80, rows: 20 };
|
||||
let (mut screen, capture) = create_new_screen_with_theme_capture(size);
|
||||
screen.host_theme_dark_styling = Some(styling_with_background(TEST_DARK_BG));
|
||||
|
||||
screen
|
||||
.resolve_default_theme_mode()
|
||||
.expect("nothing to resolve");
|
||||
|
||||
assert_eq!(
|
||||
screen.host_terminal_theme_mode, None,
|
||||
"a lone theme_dark leaves the static theme authoritative, so the mode \
|
||||
stays genuinely unknown"
|
||||
);
|
||||
assert!(
|
||||
capture.drain_plugin_events().is_empty(),
|
||||
"no synthetic event may be emitted while the mode is unknown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_does_not_override_an_explicit_hue() {
|
||||
let size = Size { cols: 80, rows: 20 };
|
||||
let (mut screen, _capture) = create_new_screen_with_dark_and_light_themes(size);
|
||||
screen
|
||||
.apply_configured_explicit_theme_hue(Some(zellij_utils::data::ThemeHue::Light))
|
||||
.expect("explicit hue applied");
|
||||
|
||||
screen
|
||||
.resolve_default_theme_mode()
|
||||
.expect("default is a no-op here");
|
||||
|
||||
assert_eq!(
|
||||
screen.host_terminal_theme_mode,
|
||||
Some(zellij_utils::data::HostTerminalThemeMode::Light),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_theme_hue_outranks_ambient_host_reports() {
|
||||
let size = Size { cols: 80, rows: 20 };
|
||||
let (mut screen, capture) = create_new_screen_with_dark_and_light_themes(size);
|
||||
screen
|
||||
.apply_configured_explicit_theme_hue(Some(zellij_utils::data::ThemeHue::Light))
|
||||
.expect("explicit hue applied");
|
||||
let _ = capture.drain_plugin_events();
|
||||
|
||||
screen
|
||||
.update_host_terminal_theme_mode(zellij_utils::data::HostTerminalThemeMode::Dark)
|
||||
.expect("ambient report absorbed");
|
||||
|
||||
assert_eq!(
|
||||
screen.host_terminal_theme_mode,
|
||||
Some(zellij_utils::data::HostTerminalThemeMode::Light),
|
||||
"the pinned mode must survive an ambient report to the contrary"
|
||||
);
|
||||
assert_eq!(
|
||||
screen.style.colors.text_unselected.background,
|
||||
zellij_utils::data::PaletteColor::Rgb(TEST_LIGHT_BG),
|
||||
"the palette must not be swapped while pinned"
|
||||
);
|
||||
assert!(
|
||||
capture.drain_plugin_events().is_empty(),
|
||||
"a suppressed report is not a mode change and must not reach plugins"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_theme_action_pins_the_session() {
|
||||
let size = Size { cols: 80, rows: 20 };
|
||||
let (mut screen, capture) = create_new_screen_with_dark_and_light_themes(size);
|
||||
let mut completion_tx = None;
|
||||
|
||||
screen
|
||||
.apply_manual_host_terminal_theme_mode(
|
||||
zellij_utils::data::HostTerminalThemeMode::Dark,
|
||||
&mut completion_tx,
|
||||
)
|
||||
.expect("manual switch ok");
|
||||
let _ = capture.drain_plugin_events();
|
||||
|
||||
screen
|
||||
.update_host_terminal_theme_mode(zellij_utils::data::HostTerminalThemeMode::Light)
|
||||
.expect("ambient report absorbed");
|
||||
|
||||
assert_eq!(
|
||||
screen.host_terminal_theme_mode,
|
||||
Some(zellij_utils::data::HostTerminalThemeMode::Dark),
|
||||
"a deliberate choice must not be reverted by the host terminal"
|
||||
);
|
||||
assert!(
|
||||
capture.drain_plugin_events().is_empty(),
|
||||
"no mode change occurred, so no plugin event may be emitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_explicit_theme_hue_hands_authority_back_to_the_host() {
|
||||
let size = Size { cols: 80, rows: 20 };
|
||||
let (mut screen, capture) = create_new_screen_with_dark_and_light_themes(size);
|
||||
screen
|
||||
.apply_configured_explicit_theme_hue(Some(zellij_utils::data::ThemeHue::Light))
|
||||
.expect("explicit hue applied");
|
||||
screen
|
||||
.update_host_terminal_theme_mode(zellij_utils::data::HostTerminalThemeMode::Dark)
|
||||
.expect("ambient report recorded while pinned");
|
||||
let _ = capture.drain_plugin_events();
|
||||
|
||||
screen
|
||||
.apply_configured_explicit_theme_hue(None)
|
||||
.expect("unpinned");
|
||||
|
||||
assert_eq!(
|
||||
screen.host_terminal_theme_mode,
|
||||
Some(zellij_utils::data::HostTerminalThemeMode::Dark),
|
||||
"unpinning must restore the ambient report that was suppressed"
|
||||
);
|
||||
assert_eq!(
|
||||
screen.style.colors.text_unselected.background,
|
||||
zellij_utils::data::PaletteColor::Rgb(TEST_DARK_BG),
|
||||
"the palette must follow the restored mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_theme_mode_is_reasserted_after_a_theme_definition_change() {
|
||||
let size = Size { cols: 80, rows: 20 };
|
||||
let (mut screen, _capture) = create_new_screen_with_dark_and_light_themes(size);
|
||||
screen
|
||||
.update_host_terminal_theme_mode(zellij_utils::data::HostTerminalThemeMode::Light)
|
||||
.expect("host report applied");
|
||||
|
||||
let new_light = styling_with_background((250, 250, 250));
|
||||
screen.host_theme_light_styling = Some(new_light);
|
||||
screen.style.colors = styling_with_background((1, 2, 3));
|
||||
|
||||
screen
|
||||
.reapply_effective_theme_mode()
|
||||
.expect("mode reasserted");
|
||||
|
||||
assert_eq!(
|
||||
screen.style.colors.text_unselected.background,
|
||||
zellij_utils::data::PaletteColor::Rgb((250, 250, 250)),
|
||||
"the session's mode must be re-resolved against the new definitions \
|
||||
instead of falling back to the static theme"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_theme_no_pty_writes_when_no_panes_subscribed() {
|
||||
let size = Size { cols: 80, rows: 20 };
|
||||
|
||||
@@ -367,6 +367,21 @@ load_plugins {
|
||||
//
|
||||
// theme "default"
|
||||
|
||||
// Themes to use when the host terminal reports a dark or light color palette.
|
||||
// Both must be set for automatic switching to take place, otherwise `theme`
|
||||
// remains authoritative. Until the host terminal reports (or if it never does),
|
||||
// the dark theme is used.
|
||||
//
|
||||
// theme_dark "nightfox"
|
||||
// theme_light "dayfox"
|
||||
|
||||
// Pin the session to a dark or light appearance, ignoring what the host
|
||||
// terminal reports. Resolved before the first render. When unset, the host
|
||||
// terminal decides.
|
||||
// Options: dark, light
|
||||
//
|
||||
// explicit_theme_hue "dark"
|
||||
|
||||
// The name of the default layout to load on startup
|
||||
// Default: "default"
|
||||
// (Requires restart)
|
||||
|
||||
@@ -2064,6 +2064,8 @@ pub struct Options {
|
||||
pub host_notification_protocol: ::core::option::Option<::prost::alloc::string::String>,
|
||||
#[prost(bool, optional, tag="68")]
|
||||
pub scroll_mode_sync: ::core::option::Option<bool>,
|
||||
#[prost(enumeration="ThemeHue", optional, tag="69")]
|
||||
pub explicit_theme_hue: ::core::option::Option<i32>,
|
||||
}
|
||||
/// Pane-targeting action messages
|
||||
#[allow(clippy::derive_partial_eq_without_eq)]
|
||||
@@ -3032,6 +3034,35 @@ impl NestedSessionHandling {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
|
||||
#[repr(i32)]
|
||||
pub enum ThemeHue {
|
||||
Unspecified = 0,
|
||||
Dark = 1,
|
||||
Light = 2,
|
||||
}
|
||||
impl ThemeHue {
|
||||
/// String value of the enum field names used in the ProtoBuf definition.
|
||||
///
|
||||
/// The values are not transformed in any way and thus are considered stable
|
||||
/// (if the ProtoBuf definition does not change) and safe for programmatic use.
|
||||
pub fn as_str_name(&self) -> &'static str {
|
||||
match self {
|
||||
ThemeHue::Unspecified => "THEME_HUE_UNSPECIFIED",
|
||||
ThemeHue::Dark => "THEME_HUE_DARK",
|
||||
ThemeHue::Light => "THEME_HUE_LIGHT",
|
||||
}
|
||||
}
|
||||
/// Creates an enum from field names used in the ProtoBuf definition.
|
||||
pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
|
||||
match value {
|
||||
"THEME_HUE_UNSPECIFIED" => Some(Self::Unspecified),
|
||||
"THEME_HUE_DARK" => Some(Self::Dark),
|
||||
"THEME_HUE_LIGHT" => Some(Self::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[allow(clippy::derive_partial_eq_without_eq)]
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct ClientToServerMsg {
|
||||
|
||||
@@ -1224,6 +1224,7 @@ message Options {
|
||||
optional bool mouse_hover_tips = 59;
|
||||
optional string host_notification_protocol = 67;
|
||||
optional bool scroll_mode_sync = 68;
|
||||
optional ThemeHue explicit_theme_hue = 69;
|
||||
}
|
||||
|
||||
enum OnForceClose {
|
||||
@@ -1259,6 +1260,12 @@ enum NestedSessionHandling {
|
||||
NESTED_SESSION_HANDLING_NEVER = 4;
|
||||
}
|
||||
|
||||
enum ThemeHue {
|
||||
THEME_HUE_UNSPECIFIED = 0;
|
||||
THEME_HUE_DARK = 1;
|
||||
THEME_HUE_LIGHT = 2;
|
||||
}
|
||||
|
||||
|
||||
// Pane-targeting action messages
|
||||
message ScrollUpByPaneIdAction { PaneId pane_id = 1; }
|
||||
|
||||
@@ -1208,9 +1208,11 @@ impl Default for InputMode {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, ValueEnum)]
|
||||
pub enum ThemeHue {
|
||||
#[serde(alias = "light")]
|
||||
Light,
|
||||
#[serde(alias = "dark")]
|
||||
Dark,
|
||||
}
|
||||
impl Default for ThemeHue {
|
||||
@@ -1219,6 +1221,47 @@ impl Default for ThemeHue {
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ThemeHue {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"light" => Ok(ThemeHue::Light),
|
||||
"dark" => Ok(ThemeHue::Dark),
|
||||
e => Err(format!(
|
||||
"Unknown theme hue: '{}' (expected 'dark' or 'light')",
|
||||
e
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ThemeHue {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ThemeHue::Light => write!(f, "light"),
|
||||
ThemeHue::Dark => write!(f, "dark"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ThemeHue> for HostTerminalThemeMode {
|
||||
fn from(hue: ThemeHue) -> Self {
|
||||
match hue {
|
||||
ThemeHue::Light => HostTerminalThemeMode::Light,
|
||||
ThemeHue::Dark => HostTerminalThemeMode::Dark,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HostTerminalThemeMode> for ThemeHue {
|
||||
fn from(mode: HostTerminalThemeMode) -> Self {
|
||||
match mode {
|
||||
HostTerminalThemeMode::Light => ThemeHue::Light,
|
||||
HostTerminalThemeMode::Dark => ThemeHue::Dark,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub enum PaletteColor {
|
||||
Rgb((u8, u8, u8)),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Handles cli and configuration options
|
||||
use crate::cli::Command;
|
||||
use crate::data::{InputMode, WebSharing};
|
||||
use crate::data::{InputMode, ThemeHue, WebSharing};
|
||||
use clap::{Args, ValueEnum};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
@@ -141,6 +141,12 @@ pub struct Options {
|
||||
/// is missing the static `theme` remains authoritative.
|
||||
#[clap(long, value_parser)]
|
||||
pub theme_light: Option<String>,
|
||||
/// Pin the session to a dark or light appearance ("dark" or "light"),
|
||||
/// resolved before the first render and kept authoritative over ambient
|
||||
/// host terminal reports (CSI 2031 / DSR 997). When unset, the session
|
||||
/// follows the host terminal.
|
||||
#[clap(long, value_enum, hide_possible_values = true, value_parser)]
|
||||
pub explicit_theme_hue: Option<ThemeHue>,
|
||||
/// Set the default mode
|
||||
#[clap(long, value_enum, hide_possible_values = true, value_parser)]
|
||||
pub default_mode: Option<InputMode>,
|
||||
@@ -520,6 +526,7 @@ impl Options {
|
||||
let theme = other.theme.or_else(|| self.theme.clone());
|
||||
let theme_dark = other.theme_dark.or_else(|| self.theme_dark.clone());
|
||||
let theme_light = other.theme_light.or_else(|| self.theme_light.clone());
|
||||
let explicit_theme_hue = other.explicit_theme_hue.or(self.explicit_theme_hue);
|
||||
let on_force_close = other.on_force_close.or(self.on_force_close);
|
||||
let scroll_buffer_size = other.scroll_buffer_size.or(self.scroll_buffer_size);
|
||||
let copy_command = other.copy_command.or_else(|| self.copy_command.clone());
|
||||
@@ -601,6 +608,7 @@ impl Options {
|
||||
theme,
|
||||
theme_dark,
|
||||
theme_light,
|
||||
explicit_theme_hue,
|
||||
default_mode,
|
||||
default_shell,
|
||||
default_cwd,
|
||||
@@ -693,6 +701,7 @@ impl Options {
|
||||
let theme = other.theme.or_else(|| self.theme.clone());
|
||||
let theme_dark = other.theme_dark.or_else(|| self.theme_dark.clone());
|
||||
let theme_light = other.theme_light.or_else(|| self.theme_light.clone());
|
||||
let explicit_theme_hue = other.explicit_theme_hue.or(self.explicit_theme_hue);
|
||||
let on_force_close = other.on_force_close.or(self.on_force_close);
|
||||
let scroll_buffer_size = other.scroll_buffer_size.or(self.scroll_buffer_size);
|
||||
let copy_command = other.copy_command.or_else(|| self.copy_command.clone());
|
||||
@@ -770,6 +779,7 @@ impl Options {
|
||||
theme,
|
||||
theme_dark,
|
||||
theme_light,
|
||||
explicit_theme_hue,
|
||||
default_mode,
|
||||
default_shell,
|
||||
default_cwd,
|
||||
|
||||
@@ -984,6 +984,13 @@ impl From<crate::input::options::Options>
|
||||
NestedSessionHandling::Never => ProtoNestedSessionHandling::Never as i32,
|
||||
}
|
||||
}),
|
||||
explicit_theme_hue: options.explicit_theme_hue.map(|hue| {
|
||||
use crate::client_server_contract::client_server_contract::ThemeHue as ProtoThemeHue;
|
||||
match hue {
|
||||
crate::data::ThemeHue::Dark => ProtoThemeHue::Dark as i32,
|
||||
crate::data::ThemeHue::Light => ProtoThemeHue::Light as i32,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1120,6 +1127,17 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Options>
|
||||
_ => Err(anyhow!("Invalid NestedSessionHandling value: {}", n)),
|
||||
})
|
||||
.transpose()?,
|
||||
explicit_theme_hue: options
|
||||
.explicit_theme_hue
|
||||
.map(|hue| {
|
||||
use crate::client_server_contract::client_server_contract::ThemeHue as ProtoThemeHue;
|
||||
match ProtoThemeHue::try_from(hue).ok() {
|
||||
Some(ProtoThemeHue::Dark) => Ok(crate::data::ThemeHue::Dark),
|
||||
Some(ProtoThemeHue::Light) => Ok(crate::data::ThemeHue::Light),
|
||||
_ => Err(anyhow!("Invalid ThemeHue value: {}", hue)),
|
||||
}
|
||||
})
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,6 +456,7 @@ fn test_client_messages() {
|
||||
theme: Some("theme".to_owned()),
|
||||
theme_dark: Some("theme_dark".to_owned()),
|
||||
theme_light: Some("theme_light".to_owned()),
|
||||
explicit_theme_hue: Some(crate::data::ThemeHue::Light),
|
||||
default_mode: Some(InputMode::Normal),
|
||||
default_shell: Some(PathBuf::from("default_shell")),
|
||||
default_cwd: Some(PathBuf::from("default_cwd")),
|
||||
|
||||
@@ -2,7 +2,7 @@ mod kdl_layout_parser;
|
||||
use crate::data::{
|
||||
BareKey, Direction, FloatingPaneCoordinates, InputMode, KeyWithModifier, LayoutInfo,
|
||||
LayoutMetadata, MultiplayerColors, Palette, PaletteColor, PaneId, PaneInfo, PaneManifest,
|
||||
PermissionType, Resize, SessionInfo, StyleDeclaration, Styling, TabInfo, WebSharing,
|
||||
PermissionType, Resize, SessionInfo, StyleDeclaration, Styling, TabInfo, ThemeHue, WebSharing,
|
||||
DEFAULT_STYLES,
|
||||
};
|
||||
use crate::envs::EnvironmentVariables;
|
||||
@@ -2783,6 +2783,16 @@ impl Options {
|
||||
.map(|(theme, _entry)| theme.to_string());
|
||||
let theme_light = kdl_property_first_arg_as_string_or_error!(kdl_options, "theme_light")
|
||||
.map(|(theme, _entry)| theme.to_string());
|
||||
let explicit_theme_hue =
|
||||
match kdl_property_first_arg_as_string_or_error!(kdl_options, "explicit_theme_hue") {
|
||||
Some((string, entry)) => Some(ThemeHue::from_str(string).map_err(|_| {
|
||||
kdl_parsing_error!(
|
||||
format!("Invalid value for explicit_theme_hue: '{}'", string),
|
||||
entry
|
||||
)
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
let default_mode =
|
||||
match kdl_property_first_arg_as_string_or_error!(kdl_options, "default_mode") {
|
||||
Some((string, entry)) => Some(InputMode::from_str(string).map_err(|_| {
|
||||
@@ -2986,6 +2996,7 @@ impl Options {
|
||||
theme,
|
||||
theme_dark,
|
||||
theme_light,
|
||||
explicit_theme_hue,
|
||||
default_mode,
|
||||
default_shell,
|
||||
default_cwd,
|
||||
@@ -3190,6 +3201,35 @@ impl Options {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn explicit_theme_hue_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
|
||||
let comment_text = format!(
|
||||
"{}\n{}\n{}\n{}\n{}",
|
||||
" ",
|
||||
"// Pin the session to a dark or light appearance, ignoring what the",
|
||||
"// host terminal reports. When unset, the host terminal decides.",
|
||||
"// Options: dark, light",
|
||||
"// ",
|
||||
);
|
||||
|
||||
let create_node = |hue: &ThemeHue| -> KdlNode {
|
||||
let mut node = KdlNode::new("explicit_theme_hue");
|
||||
node.push(format!("{}", hue));
|
||||
node
|
||||
};
|
||||
if let Some(explicit_theme_hue) = &self.explicit_theme_hue {
|
||||
let mut node = create_node(explicit_theme_hue);
|
||||
if add_comments {
|
||||
node.set_leading(format!("{}\n", comment_text));
|
||||
}
|
||||
Some(node)
|
||||
} else if add_comments {
|
||||
let mut node = create_node(&ThemeHue::Dark);
|
||||
node.set_leading(format!("{}\n// ", comment_text));
|
||||
Some(node)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn default_mode_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
|
||||
let comment_text = format!(
|
||||
"{}\n{}\n{}\n{}",
|
||||
@@ -4758,6 +4798,9 @@ impl Options {
|
||||
if let Some(theme_light_node) = self.theme_light_to_kdl(add_comments) {
|
||||
nodes.push(theme_light_node);
|
||||
}
|
||||
if let Some(explicit_theme_hue_node) = self.explicit_theme_hue_to_kdl(add_comments) {
|
||||
nodes.push(explicit_theme_hue_node);
|
||||
}
|
||||
if let Some(default_mode) = self.default_mode_to_kdl(add_comments) {
|
||||
nodes.push(default_mode);
|
||||
}
|
||||
@@ -7727,6 +7770,54 @@ fn scroll_mode_sync_round_trips_through_kdl() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_theme_hue_from_kdl() {
|
||||
let fake_config = r##"
|
||||
explicit_theme_hue "light"
|
||||
"##;
|
||||
let document: KdlDocument = fake_config.parse().unwrap();
|
||||
let deserialized = Options::from_kdl(&document).unwrap();
|
||||
assert_eq!(deserialized.explicit_theme_hue, Some(ThemeHue::Light));
|
||||
|
||||
let empty_document: KdlDocument = "".parse().unwrap();
|
||||
let deserialized_empty = Options::from_kdl(&empty_document).unwrap();
|
||||
assert_eq!(
|
||||
deserialized_empty.explicit_theme_hue, None,
|
||||
"an unspecified explicit_theme_hue leaves the host terminal in charge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_theme_hue_rejects_unknown_values() {
|
||||
let fake_config = r##"
|
||||
explicit_theme_hue "sepia"
|
||||
"##;
|
||||
let document: KdlDocument = fake_config.parse().unwrap();
|
||||
assert!(
|
||||
Options::from_kdl(&document).is_err(),
|
||||
"only 'dark' and 'light' are accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_theme_hue_round_trips_through_kdl() {
|
||||
let fake_config = r##"
|
||||
explicit_theme_hue "dark"
|
||||
"##;
|
||||
let document: KdlDocument = fake_config.parse().unwrap();
|
||||
let deserialized = Options::from_kdl(&document).unwrap();
|
||||
let mut serialized = Options::to_kdl(&deserialized, false);
|
||||
let mut fake_document = KdlDocument::new();
|
||||
fake_document.nodes_mut().append(&mut serialized);
|
||||
let deserialized_from_serialized =
|
||||
Options::from_kdl(&fake_document.to_string().parse::<KdlDocument>().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
deserialized_from_serialized.explicit_theme_hue,
|
||||
Some(ThemeHue::Dark),
|
||||
"explicit_theme_hue survives a serialize/parse round trip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_options_to_string() {
|
||||
let fake_config = r##"
|
||||
|
||||
+6
@@ -322,6 +322,12 @@ web_client {
|
||||
//
|
||||
// theme_light "solarized-light"
|
||||
|
||||
// Pin the session to a dark or light appearance, ignoring what the
|
||||
// host terminal reports. When unset, the host terminal decides.
|
||||
// Options: dark, light
|
||||
//
|
||||
// explicit_theme_hue "dark"
|
||||
|
||||
// Choose the base input mode of zellij.
|
||||
// Default: normal
|
||||
//
|
||||
|
||||
+6
@@ -32,6 +32,12 @@ theme "dracula"
|
||||
//
|
||||
// theme_light "solarized-light"
|
||||
|
||||
// Pin the session to a dark or light appearance, ignoring what the
|
||||
// host terminal reports. When unset, the host terminal decides.
|
||||
// Options: dark, light
|
||||
//
|
||||
// explicit_theme_hue "dark"
|
||||
|
||||
// Choose the base input mode of zellij.
|
||||
// Default: normal
|
||||
//
|
||||
|
||||
+1
@@ -9,6 +9,7 @@ Options {
|
||||
theme: None,
|
||||
theme_dark: None,
|
||||
theme_light: None,
|
||||
explicit_theme_hue: None,
|
||||
default_mode: None,
|
||||
default_shell: None,
|
||||
default_cwd: None,
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ Options {
|
||||
theme: None,
|
||||
theme_dark: None,
|
||||
theme_light: None,
|
||||
explicit_theme_hue: None,
|
||||
default_mode: None,
|
||||
default_shell: None,
|
||||
default_cwd: None,
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ Options {
|
||||
theme: None,
|
||||
theme_dark: None,
|
||||
theme_light: None,
|
||||
explicit_theme_hue: None,
|
||||
default_mode: None,
|
||||
default_shell: None,
|
||||
default_cwd: None,
|
||||
|
||||
+1
@@ -6099,6 +6099,7 @@ Config {
|
||||
theme: None,
|
||||
theme_dark: None,
|
||||
theme_light: None,
|
||||
explicit_theme_hue: None,
|
||||
default_mode: None,
|
||||
default_shell: None,
|
||||
default_cwd: None,
|
||||
|
||||
+1
@@ -6099,6 +6099,7 @@ Config {
|
||||
theme: None,
|
||||
theme_dark: None,
|
||||
theme_light: None,
|
||||
explicit_theme_hue: None,
|
||||
default_mode: None,
|
||||
default_shell: None,
|
||||
default_cwd: None,
|
||||
|
||||
+1
@@ -94,6 +94,7 @@ Config {
|
||||
theme: None,
|
||||
theme_dark: None,
|
||||
theme_light: None,
|
||||
explicit_theme_hue: None,
|
||||
default_mode: None,
|
||||
default_shell: None,
|
||||
default_cwd: None,
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ Options {
|
||||
theme: None,
|
||||
theme_dark: None,
|
||||
theme_light: None,
|
||||
explicit_theme_hue: None,
|
||||
default_mode: None,
|
||||
default_shell: None,
|
||||
default_cwd: None,
|
||||
|
||||
+1
@@ -6099,6 +6099,7 @@ Config {
|
||||
theme: None,
|
||||
theme_dark: None,
|
||||
theme_light: None,
|
||||
explicit_theme_hue: None,
|
||||
default_mode: None,
|
||||
default_shell: None,
|
||||
default_cwd: None,
|
||||
|
||||
+1
@@ -6099,6 +6099,7 @@ Config {
|
||||
theme: None,
|
||||
theme_dark: None,
|
||||
theme_light: None,
|
||||
explicit_theme_hue: None,
|
||||
default_mode: None,
|
||||
default_shell: None,
|
||||
default_cwd: None,
|
||||
|
||||
Reference in New Issue
Block a user