From 8721103f4ca2bc5fb08c6efe8bb73a46646200da Mon Sep 17 00:00:00 2001 From: Michael Adler Date: Thu, 30 Jul 2026 23:31:19 +0200 Subject: [PATCH] feat: per-window theme overrides Theme properties can now be overridden per window via window rules, e.g.: ```toml [[windows]] match.app-id = "mpv" action = { type = "set-window-theme", theme.border-width = 0 } ``` If several rules override the same property of a window, the rule that appears last takes precedence. --- jay-config/src/_private/client.rs | 16 +++ jay-config/src/_private/ipc.rs | 10 ++ jay-config/src/window.rs | 17 +++ src/config/handler.rs | 60 +++++++++++ src/renderer.rs | 21 ++-- src/state.rs | 33 +++++- src/theme.rs | 132 +++++++++++++++++++++-- src/tree/container.rs | 2 +- src/tree/float.rs | 78 ++++++++++---- src/tree/toplevel.rs | 35 ++++++ toml-config/src/config.rs | 3 + toml-config/src/config/parsers/action.rs | 11 ++ toml-config/src/lib.rs | 110 +++++++++++++++++++ toml-spec/spec/spec.generated.json | 17 +++ toml-spec/spec/spec.generated.md | 44 ++++++++ toml-spec/spec/spec.yaml | 40 +++++++ 16 files changed, 584 insertions(+), 45 deletions(-) diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 74ff92c24..e31ae13a0 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -2311,6 +2311,22 @@ impl ConfigClient { self.send(&ClientMessage::SetWindowMatcherInitialFloatingPosition { matcher, x, y }); } + pub fn set_window_size(&self, window: Window, sized: Resizable, size: Option) { + self.send(&ClientMessage::SetWindowSize { + window, + sized, + size, + }); + } + + pub fn set_window_color(&self, window: Window, colorable: Colorable, color: Option) { + self.send(&ClientMessage::SetWindowColor { + window, + colorable, + color, + }); + } + pub fn set_window_matcher_latch_handler( &self, matcher: WindowMatcher, diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index ce0338f99..f716e662d 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -1084,6 +1084,16 @@ pub enum ClientMessage<'a> { target: ContainerTarget, axis: RelativeAxis, }, + SetWindowSize { + window: Window, + sized: Resizable, + size: Option, + }, + SetWindowColor { + window: Window, + colorable: Colorable, + color: Option, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/window.rs b/jay-config/src/window.rs index 0d10f4a88..7273f616e 100644 --- a/jay-config/src/window.rs +++ b/jay-config/src/window.rs @@ -8,6 +8,9 @@ use crate::Workspace; use crate::client::Client; use crate::client::ClientCriterion; use crate::input::Seat; +use crate::theme::Color; +use crate::theme::colors::Colorable; +use crate::theme::sized::Resizable; use serde::Deserialize; use serde::Serialize; use std::ops::Deref; @@ -309,6 +312,20 @@ impl Window { let (_, _, width, height) = get!((0, 0)).get_window_position(self); (width, height) } + + /// Overrides a theme size for this window. + /// + /// A value of `None` removes the override so that the global theme size is used. + pub fn set_size(self, sized: Resizable, size: Option) { + get!().set_window_size(self, sized, size); + } + + /// Overrides a theme color for this window. + /// + /// A value of `None` removes the override so that the global theme color is used. + pub fn set_color(self, colorable: Colorable, color: Option) { + get!().set_window_color(self, colorable, color); + } } /// A window matcher. diff --git a/src/config/handler.rs b/src/config/handler.rs index 8042008e3..20a7166b8 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -45,6 +45,7 @@ use crate::state::OutputData; use crate::state::State; use crate::tagged_acceptor::TaggedAcceptorError; use crate::theme::ThemeColored; +use crate::theme::ThemeOverrides; use crate::theme::ThemeSized; use crate::tree::ContainerSplit; use crate::tree::ContainerTarget; @@ -2886,6 +2887,40 @@ impl ConfigProxyHandler { Ok(()) } + fn handle_set_window_size( + &self, + window: Window, + sized: Resizable, + size: Option, + ) -> Result<(), CphError> { + let sized = self.get_sized(sized)?; + if let Some(size) = size + && (size < sized.min() || size > sized.max()) + { + return Err(CphError::InvalidSize(size, sized)); + } + let window = self.get_window(window)?; + if update_theme_overrides(window.tl_data(), |o| o.sizes[sized][LiveTL].set(size)) { + self.state.spaces_changed(); + } + Ok(()) + } + + fn handle_set_window_color( + &self, + window: Window, + colorable: Colorable, + color: Option, + ) -> Result<(), CphError> { + let colored = self.get_color(colorable)?; + let window = self.get_window(window)?; + let color = color.map(|c| c.into()); + if update_theme_overrides(window.tl_data(), |o| o.colors[colored][LiveTL].set(color)) { + self.state.colors_changed(); + } + Ok(()) + } + fn handle_set_pointer_revert_key(&self, seat: Seat, key: KeySym) -> Result<(), CphError> { self.get_seat(seat)?.set_pointer_revert_key(key); Ok(()) @@ -4011,6 +4046,20 @@ impl ConfigProxyHandler { ClientMessage::SetWindowMatcherInitialFloatingPosition { matcher, x, y } => self .handle_set_window_matcher_initial_floating_position(matcher, x, y) .wrn("set_window_matcher_initial_floating_position")?, + ClientMessage::SetWindowSize { + window, + sized, + size, + } => self + .handle_set_window_size(window, sized, size) + .wrn("set_window_size")?, + ClientMessage::SetWindowColor { + window, + colorable, + color, + } => self + .handle_set_window_color(window, colorable, color) + .wrn("set_window_color")?, ClientMessage::SetPointerRevertKey { seat, key } => self .handle_set_pointer_revert_key(seat, key) .wrn("set_pointer_revert_key")?, @@ -4371,6 +4420,17 @@ impl ConfigProxyHandler { } } +/// Applies `f` to the theme overrides of `data` and returns whether they changed. +fn update_theme_overrides(data: &ToplevelData, f: impl FnOnce(&mut ThemeOverrides)) -> bool { + let mut new = data.theme_overrides.clone(); + f(&mut new); + if data.theme_overrides == new { + return false; + } + data.set_theme_overrides(new); + true +} + #[derive(Debug, Error)] enum CphError { #[error("Tried to set an unknown accel profile: {}", (.0).0)] diff --git a/src/renderer.rs b/src/renderer.rs index 461e86a6b..fedba820e 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -22,6 +22,7 @@ use crate::renderer::renderer_base::RendererBase; use crate::scale::Scale; use crate::state::State; use crate::theme::Color; +use crate::theme::ThemeColored; use crate::tree::ContainerChildType; use crate::tree::ContainerNode; use crate::tree::DisplayNode; @@ -339,7 +340,7 @@ impl Renderer<'_> { let c = self.state.theme.colors.separator.get(); self.base .fill_boxes2(&rd.underline_rects, &c, srgb, perceptual, x, y); - let c = self.state.theme.focused_border_color(); + let c = self.state.theme.focused_border_color(RenderTL); self.base .fill_boxes2(&rd.active_border_rects, &c, srgb, perceptual, x, y); let c = self.state.theme.colors.border.get(); @@ -646,23 +647,25 @@ impl Renderer<'_> { _ => return, }; let pos = ns.position.get(); - let theme = &self.state.theme; + let theme = self + .state + .theme_view(Some(&child.tl_data().theme_overrides)); let th = theme.title_height(RenderTL); let tpuh = theme.title_plus_underline_height(RenderTL); let tuh = theme.title_underline_height(RenderTL); - let bw = theme.sizes.border_width.get(RenderTL); + let bw = theme.border_width(RenderTL); let bc = match ns.active.get() { - true => theme.focused_border_color(), - false => theme.colors.border.get(), + true => theme.focused_border_color(RenderTL), + false => theme.color(ThemeColored::border, RenderTL), }; let tc = if ns.active.get() { - theme.colors.focused_title_background.get() + theme.color(ThemeColored::focused_title_background, RenderTL) } else if ns.attention_requested.get() { - theme.colors.attention_requested_background.get() + theme.color(ThemeColored::attention_requested_background, RenderTL) } else { - theme.colors.unfocused_title_background.get() + theme.color(ThemeColored::unfocused_title_background, RenderTL) }; - let uc = theme.colors.separator.get(); + let uc = theme.color(ThemeColored::separator, RenderTL); let borders = [ Rect::new_sized_saturating(x, y, pos.width(), bw), Rect::new_sized_saturating(x, y + bw, bw, pos.height() - bw), diff --git a/src/state.rs b/src/state.rs index 75696f8e5..b641e6237 100644 --- a/src/state.rs +++ b/src/state.rs @@ -168,7 +168,9 @@ use crate::theme::Color; use crate::theme::ContainerBordersSetting; use crate::theme::Theme; use crate::theme::ThemeColored; +use crate::theme::ThemeOverrides; use crate::theme::ThemeSized; +use crate::theme::ThemeView; use crate::time::Time; use crate::transactions::TransactionData; use crate::transactions::Transactionable; @@ -1997,6 +1999,32 @@ impl State { self.config.get()?.initial_tile_state(data) } + /// Returns a view of the theme through `overrides`. + pub fn theme_view<'a>(&'a self, overrides: Option<&'a ThemeOverrides>) -> ThemeView<'a> { + self.theme.view_with_overrides(overrides) + } + + /// Removes the theme overrides of all toplevels. + /// + /// The overrides are owned by the config, therefore they must not survive a config + /// reload. The new config re-applies them via its window rules. + fn clear_theme_overrides(self: &Rc) { + let mut any = false; + for tl in self.toplevels.lock().values() { + if let Some(tl) = tl.upgrade() { + let data = tl.tl_data(); + if !data.theme_overrides.is_empty(LiveTL) { + any = true; + data.set_theme_overrides(Default::default()); + } + } + } + if any { + self.spaces_changed(); + self.colors_changed(); + } + } + pub fn update_capabilities( &self, data: &Rc, @@ -2149,7 +2177,7 @@ impl State { } } - fn colors_changed(self: &Rc) { + pub fn colors_changed(self: &Rc) { self.colors_changed.set(true); self.theme_changed.trigger(); } @@ -2178,6 +2206,7 @@ impl State { for seat in self.globals.seats.lock().values() { seat.clear_shortcuts(); } + self.clear_theme_overrides(); } self.config_locked_shortcuts.set(false); config.configure(true); @@ -2202,7 +2231,7 @@ impl State { self.trigger_cci(CCI_COMPOSITOR); } - fn spaces_changed(self: &Rc) { + pub fn spaces_changed(self: &Rc) { self.spaces_changed.set(true); self.theme_changed.trigger(); } diff --git a/src/theme.rs b/src/theme.rs index 836d98b96..779dcba23 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -21,6 +21,7 @@ use jay_config::theme::BarPosition as ConfigBarPosition; use jay_config::theme::ContainerBorders as ConfigContainerBorders; use jay_proc::jay_clone; use linearize::Linearize; +use linearize::StaticMap; use std::cell::Cell; use std::cmp::Ordering; use std::ops::Add; @@ -658,6 +659,8 @@ pub struct Theme { pub show_window_icons: Cell, pub window_icons_grayscale: Cell, pub container_borders: SplitView>, + /// An empty set of overrides used by views without per-window overrides. + empty_overrides: ThemeOverrides, } impl Default for Theme { @@ -675,11 +678,25 @@ impl Default for Theme { show_window_icons: Cell::new(true), window_icons_grayscale: Cell::new(false), container_borders: Default::default(), + empty_overrides: Default::default(), } } } impl Theme { + /// Returns a view of this theme without any per-window overrides. + pub fn view(&self) -> ThemeView<'_> { + ThemeView::new(self, &self.empty_overrides) + } + + /// Returns a view of this theme through `overrides`. + pub fn view_with_overrides<'a>( + &'a self, + overrides: Option<&'a ThemeOverrides>, + ) -> ThemeView<'a> { + ThemeView::new(self, overrides.unwrap_or(&self.empty_overrides)) + } + pub fn title_font(&self) -> Arc { self.title_font.get().unwrap_or_else(|| self.font.get()) } @@ -689,8 +706,100 @@ impl Theme { } pub fn title_height(&self, tl: TreeTimeline) -> i32 { - if self.show_titles[tl].get() { - self.sizes.title_height.get(tl) + self.view().title_height(tl) + } + + pub fn title_icon_size(&self, tl: TreeTimeline) -> i32 { + self.view().title_icon_size(tl) + } + + pub fn title_underline_height(&self, tl: TreeTimeline) -> i32 { + self.view().title_underline_height(tl) + } + + pub fn title_plus_underline_height(&self, tl: TreeTimeline) -> i32 { + self.view().title_plus_underline_height(tl) + } + + pub fn focused_border_color(&self, tl: TreeTimeline) -> Color { + self.view().focused_border_color(tl) + } +} + +/// A sparse set of theme properties that override the global theme. +/// +/// This is the generic mechanism used to make arbitrary theme properties settable on a +/// per-window basis. Adding a new overridable property requires no changes here: it is +/// enough to add it to the `sizes!`/`colors!` macro invocations above. +/// +/// Note that overrides only affect the parts of the tree that are owned by a single +/// window. Sizes therefore only take effect for floating windows since the borders and +/// title bars of tiled windows are shared between siblings. +#[derive(Clone, Default, Debug, PartialEq)] +pub struct ThemeOverrides { + pub sizes: StaticMap>>>, + pub colors: StaticMap>>>, +} + +impl ThemeOverrides { + pub fn is_empty(&self, tl: TreeTimeline) -> bool { + self.sizes.values().all(|v| v[tl].get().is_none()) + && self.colors.values().all(|v| v[tl].get().is_none()) + } +} + +/// A view of the theme through a set of overrides. +/// +/// Views without per-window overrides borrow an empty set of overrides from the theme +/// itself. +/// +/// All code that renders or lays out a window should retrieve theme properties through a +/// view instead of accessing [`Theme`] directly. That way per-window overrides apply +/// automatically to every property. +#[derive(Copy, Clone)] +pub struct ThemeView<'a> { + pub theme: &'a Theme, + pub overrides: &'a ThemeOverrides, +} + +impl<'a> ThemeView<'a> { + pub fn new(theme: &'a Theme, overrides: &'a ThemeOverrides) -> Self { + Self { theme, overrides } + } + + pub fn size(&self, sized: ThemeSized, tl: TreeTimeline) -> i32 { + // Values are validated when the override is set. + if let Some(v) = self.overrides.sizes[sized][tl].get() { + return v; + } + sized.field(self.theme).get(tl) + } + + pub fn color(&self, colored: ThemeColored, tl: TreeTimeline) -> Color { + if let Some(v) = self.overrides.colors[colored][tl].get() { + return v; + } + colored.field(self.theme).get() + } + + fn color_is_set(&self, colored: ThemeColored, tl: TreeTimeline) -> bool { + if self.overrides.colors[colored][tl].get().is_some() { + return true; + } + colored.field(self.theme).set.get() + } + + pub fn border_width(&self, tl: TreeTimeline) -> i32 { + self.size(ThemeSized::border_width, tl) + } + + pub fn show_titles(&self, tl: TreeTimeline) -> bool { + self.theme.show_titles[tl].get() + } + + pub fn title_height(&self, tl: TreeTimeline) -> i32 { + if self.show_titles(tl) { + self.size(ThemeSized::title_height, tl) } else { 0 } @@ -701,23 +810,26 @@ impl Theme { } pub fn title_underline_height(&self, tl: TreeTimeline) -> i32 { - if self.show_titles[tl].get() { 1 } else { 0 } + if self.show_titles(tl) { 1 } else { 0 } } pub fn title_plus_underline_height(&self, tl: TreeTimeline) -> i32 { - if self.show_titles[tl].get() { - self.sizes.title_height.get(tl) + 1 + if self.show_titles(tl) { + self.size(ThemeSized::title_height, tl) + 1 } else { 0 } } - pub fn focused_border_color(&self) -> Color { - let c = &self.colors; - if c.focused_border.set.get() { - c.focused_border.val.get() + pub fn title_font(&self) -> Arc { + self.theme.title_font() + } + + pub fn focused_border_color(&self, tl: TreeTimeline) -> Color { + if self.color_is_set(ThemeColored::focused_border, tl) { + self.color(ThemeColored::focused_border, tl) } else { - c.border.val.get() + self.color(ThemeColored::border, tl) } } } diff --git a/src/tree/container.rs b/src/tree/container.rs index e868314c3..0807b3ca5 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -1029,7 +1029,7 @@ impl ContainerNode { let abs_y = ns.abs_y1.get(); self.update_child_types(); let use_active_border_rects = cb == ContainerBorders::Full - && theme.colors.border.get() != theme.focused_border_color(); + && theme.colors.border.get() != theme.focused_border_color(RenderTL); let fill_active_borders = !mono && use_active_border_rects; let add_border = |rd: &mut ContainerRenderData, x1: i32, diff --git a/src/tree/float.rs b/src/tree/float.rs index 2d6245b28..23ff13fe0 100644 --- a/src/tree/float.rs +++ b/src/tree/float.rs @@ -17,6 +17,8 @@ use crate::renderer::Renderer; use crate::scale::Scale; use crate::state::State; use crate::text::TextTexture; +use crate::theme::ThemeColored; +use crate::theme::ThemeOverrides; use crate::transactions::TransactionData; use crate::transactions::Transactionable; use crate::transactions::TransactionableExt; @@ -234,8 +236,21 @@ impl FloatNode { floater } + /// Returns the window whose theme overrides apply to this float. + /// + /// The returned node must be kept alive for as long as the [`ThemeView`] created + /// from its overrides. + fn theme_node(&self, tl: TreeTimeline) -> Option> { + self.node_state[tl].child.get() + } + pub fn on_spaces_changed(self: &Rc) { - if self.icon.set_size(self.state.theme.title_icon_size(LiveTL)) + let theme_node = self.theme_node(LiveTL); + let icon_size = self + .state + .theme_view(theme_overrides(&theme_node)) + .title_icon_size(LiveTL); + if self.icon.set_size(icon_size) && let Some(child) = self.node_state[LiveTL].child.get() { child.tl_update_icon(&self.icon); @@ -260,8 +275,9 @@ impl FloatNode { _ => return, }; let pos = ns.position.get(); - let theme = &self.state.theme; - let bw = theme.sizes.border_width.get(LiveTL); + let theme_node = self.theme_node(LiveTL); + let theme = self.state.theme_view(theme_overrides(&theme_node)); + let bw = theme.border_width(LiveTL); let th = theme.title_height(LiveTL); let tpuh = theme.title_plus_underline_height(LiveTL); let cpos = Rect::new_sized_saturating( @@ -283,10 +299,11 @@ impl FloatNode { fn render_title_phase1(&self) -> Rc { let on_completed = Rc::new(OnDropEvent::default()); - let theme = &self.state.theme; + let theme_node = self.theme_node(RenderTL); + let theme = self.state.theme_view(theme_overrides(&theme_node)); let tc = match self.node_state[RenderTL].active.get() { - true => theme.colors.focused_title_text.get(), - false => theme.colors.unfocused_title_text.get(), + true => theme.color(ThemeColored::focused_title_text, RenderTL), + false => theme.color(ThemeColored::unfocused_title_text, RenderTL), }; let font = theme.title_font(); let title = self.title.borrow_mut(); @@ -345,9 +362,10 @@ impl FloatNode { } fn render_title_phase2(&self) { - let theme = &self.state.theme; + let theme_node = self.theme_node(RenderTL); + let theme = self.state.theme_view(theme_overrides(&theme_node)); let th = theme.title_height(RenderTL); - let bw = theme.sizes.border_width.get(RenderTL); + let bw = theme.border_width(RenderTL); let title = self.title.borrow(); let tt = &*self.title_textures.borrow(); for (_, tt) in tt { @@ -374,8 +392,9 @@ impl FloatNode { ) { let x = x.round_down(); let y = y.round_down(); - let theme = &self.state.theme; - let bw = theme.sizes.border_width.get(LiveTL); + let theme_node = self.theme_node(LiveTL); + let theme = self.state.theme_view(theme_overrides(&theme_node)); + let bw = theme.border_width(LiveTL); let tpuh = theme.title_plus_underline_height(LiveTL); let mut seats = self.cursors.borrow_mut(); let seat_state = seats.entry(id).or_insert_with(|| CursorState { @@ -555,8 +574,10 @@ impl FloatNode { if pos.intersects(&opos) { return; } - let bw = self.state.theme.sizes.border_width.get(LiveTL); - let th = self.state.theme.title_height(LiveTL); + let theme_node = self.theme_node(LiveTL); + let theme = self.state.theme_view(theme_overrides(&theme_node)); + let bw = theme.border_width(LiveTL); + let th = theme.title_height(LiveTL); let mut x1 = pos.x1(); let mut x2 = pos.x2(); let mut y1 = pos.y1(); @@ -679,8 +700,10 @@ impl FloatNode { _ => return, }; let ns = &self.node_state[LiveTL]; - let bw = self.state.theme.sizes.border_width.get(LiveTL); - let th = self.state.theme.title_height(LiveTL); + let theme_node = self.theme_node(LiveTL); + let theme = self.state.theme_view(theme_overrides(&theme_node)); + let bw = theme.border_width(LiveTL); + let th = theme.title_height(LiveTL); let mut is_icon_press = false; if pressed && cursor_data.x >= bw && cursor_data.y >= bw && cursor_data.y < bw + th { enum FloatIcon { @@ -778,9 +801,10 @@ impl FloatNode { ) -> Option { let ns = &self.node_state[LiveTL]; let child = ns.child.get()?; - let theme = &self.state.theme.sizes; - let bw = theme.border_width.get(LiveTL); - let tpuh = self.state.theme.title_plus_underline_height(LiveTL); + let theme_node = self.theme_node(LiveTL); + let theme = self.state.theme_view(theme_overrides(&theme_node)); + let bw = theme.border_width(LiveTL); + let tpuh = theme.title_plus_underline_height(LiveTL); let pos = ns.position.get(); let body = Rect::new( pos.x1() + bw, @@ -920,9 +944,10 @@ impl NodeBase for FloatNode { tree: &mut Vec, usecase: FindTreeUsecase, ) -> FindTreeResult { - let theme = &self.state.theme; + let theme_node = self.theme_node(LiveTL); + let theme = self.state.theme_view(theme_overrides(&theme_node)); let tpuh = theme.title_plus_underline_height(LiveTL); - let bw = theme.sizes.border_width.get(LiveTL); + let bw = theme.border_width(LiveTL); let ns = &self.node_state[LiveTL]; let pos = ns.position.get(); if x < bw || x >= pos.width() - bw { @@ -1129,9 +1154,10 @@ impl ContainingNode for FloatNode { } fn cnode_set_child_position(self: Rc, _child: &dyn Node, x: i32, y: i32) { - let theme = &self.state.theme; + let theme_node = self.theme_node(LiveTL); + let theme = self.state.theme_view(theme_overrides(&theme_node)); let tpuh = theme.title_plus_underline_height(LiveTL); - let bw = theme.sizes.border_width.get(LiveTL); + let bw = theme.border_width(LiveTL); let (x, y) = (x - bw, y - tpuh - bw); let ns = &self.node_state[LiveTL]; let pos = ns.position.get(); @@ -1149,9 +1175,10 @@ impl ContainingNode for FloatNode { new_x2: Option, new_y2: Option, ) { - let theme = &self.state.theme; + let theme_node = self.theme_node(LiveTL); + let theme = self.state.theme_view(theme_overrides(&theme_node)); let tpuh = theme.title_plus_underline_height(LiveTL); - let bw = theme.sizes.border_width.get(LiveTL); + let bw = theme.border_width(LiveTL); let ns = &self.node_state[LiveTL]; let pos = ns.position.get(); let mut x1 = pos.x1(); @@ -1371,3 +1398,8 @@ pub fn calculate_float_position(output_rect: Rect, mut width: i32, mut height: i } Rect::new_sized_saturating(x1, y1, width, height) } + +/// Returns the theme overrides of `node`, if any. +fn theme_overrides(node: &Option>) -> Option<&ThemeOverrides> { + node.as_ref().map(|n| &n.tl_data().theme_overrides) +} diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index ae7a02a97..a05dc7f65 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -33,6 +33,10 @@ use crate::rect::Rect; use crate::sm::ToplevelSession; use crate::state::ConnectorData; use crate::state::State; +use crate::theme::Color; +use crate::theme::ThemeColored; +use crate::theme::ThemeOverrides; +use crate::theme::ThemeSized; use crate::tree::ContainerNode; use crate::tree::ContainerSplit; use crate::tree::ContainerTarget; @@ -412,6 +416,8 @@ pub enum ToplevelDataTransactionOp { SetWorkspace(Option>), SetVisible(bool), SetIsRootContainer(bool), + SetThemeColor(ThemeColored, Option), + SetThemeSize(ThemeSized, Option), } pub struct FullscreenedData { @@ -505,6 +511,8 @@ pub struct ToplevelData { pub session: CloneCell>>, pub is_root_container: SplitView>, pub is_overlay_root_container: Cell, + /// The theme overrides that apply to this toplevel, if any. + pub theme_overrides: ThemeOverrides, } impl ToplevelData { @@ -567,6 +575,7 @@ impl ToplevelData { session: Default::default(), is_root_container: Default::default(), is_overlay_root_container: Default::default(), + theme_overrides: Default::default(), } } @@ -1109,6 +1118,26 @@ impl ToplevelData { } } + /// Sets the theme overrides of this toplevel. + /// + /// The live value is updated immediately so that layout and rendering that are + /// triggered by the caller see the new value. The render value is updated via a + /// transaction so that it becomes visible together with the geometry it implies. + pub fn set_theme_overrides(&self, overrides: ThemeOverrides) { + for k in overrides.sizes.keys() { + let value = overrides.sizes[k][LiveTL].get(); + if self.theme_overrides.sizes[k][LiveTL].replace(value) != value { + self.schedule_op(ToplevelDataTransactionOp::SetThemeSize(k, value)); + } + } + for k in overrides.colors.keys() { + let value = overrides.colors[k][LiveTL].get(); + if self.theme_overrides.colors[k][LiveTL].replace(value) != value { + self.schedule_op(ToplevelDataTransactionOp::SetThemeColor(k, value)); + } + } + } + pub fn schedule_op(&self, op: ToplevelDataTransactionOp) { if let Some(slf) = self.slf.upgrade() { slf.tl_schedule_data_op(op); @@ -1128,6 +1157,12 @@ impl ToplevelData { ToplevelDataTransactionOp::SetVisible(v) => { self.visible[RenderTL].set(v); } + ToplevelDataTransactionOp::SetThemeColor(k, v) => { + self.theme_overrides.colors[k][RenderTL].set(v); + } + ToplevelDataTransactionOp::SetThemeSize(k, v) => { + self.theme_overrides.sizes[k][RenderTL].set(v); + } ToplevelDataTransactionOp::SetIsRootContainer(v) => { self.is_root_container[RenderTL].set(v); } diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index cbfe48196..e1d567567 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -181,6 +181,9 @@ pub enum Action { SetTheme { theme: Box, }, + SetWindowTheme { + theme: Box, + }, ShowWorkspace { ws: Rc, output: Option>, diff --git a/toml-config/src/config/parsers/action.rs b/toml-config/src/config/parsers/action.rs index 714096a1d..f94d6b164 100644 --- a/toml-config/src/config/parsers/action.rs +++ b/toml-config/src/config/parsers/action.rs @@ -471,6 +471,16 @@ impl ActionParser<'_, '_, '_> { }) } + fn parse_set_window_theme(&mut self, ext: &mut Extractor<'_, '_, '_>) -> ParseResult { + let theme = ext + .extract(val("theme"))? + .parse_map(&mut ThemeParser(self.0)) + .map_spanned_err(ActionParserError::Theme)?; + Ok(Action::SetWindowTheme { + theme: Box::new(theme), + }) + } + fn parse_set_log_level(&mut self, ext: &mut Extractor<'_, '_, '_>) -> ParseResult { let level = ext .extract(val("level"))? @@ -770,6 +780,7 @@ impl Parser for ActionParser<'_, '_, '_> { "set-keymap" => self.parse_set_keymap(&mut ext), "set-status" => self.parse_set_status(&mut ext), "set-theme" => self.parse_set_theme(&mut ext), + "set-window-theme" => self.parse_set_window_theme(&mut ext), "set-log-level" => self.parse_set_log_level(&mut ext), "set-gfx-api" => self.parse_set_gfx_api(&mut ext), "configure-direct-scanout" => self.parse_configure_direct_scanout(&mut ext), diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index a3ee4b975..105aaf9d4 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -464,6 +464,23 @@ impl Action { let state = state.clone(); b.new(move || state.apply_theme(&theme)) } + Action::SetWindowTheme { theme } => { + let state = state.clone(); + b.new(move || { + let window = match state.window.get() { + // Inside a window rule the action applies to the matched window. + Some(w) => w, + // Otherwise it applies to the window focused by the seat. + None => { + let w = s.window(); + w.exists().then_some(w) + } + }; + if let Some(window) = window { + apply_theme_to_window(&window, &theme); + } + }) + } Action::SetLogLevel { level } => b.new(move || set_log_level(level)), Action::SetGfxApi { api } => b.new(move || set_gfx_api(api)), Action::ConfigureDirectScanout { enabled } => { @@ -1498,6 +1515,99 @@ async fn watch_config(persistent: Rc) { pub const CONFIG_TOML: &str = "config.toml"; +fn apply_theme_to_window(window: &Window, theme: &Theme) { + use jay_config::theme::colors::*; + use jay_config::theme::sized::*; + // Destructured so that adding a field to `Theme` forces a decision here. + let Theme { + attention_requested_bg_color, + bg_color, + bar_bg_color, + bar_status_text_color, + border_color, + focused_border_color, + captured_focused_title_bg_color, + captured_unfocused_title_bg_color, + focused_inactive_title_bg_color, + focused_inactive_title_text_color, + focused_title_bg_color, + focused_title_text_color, + separator_color, + unfocused_title_bg_color, + unfocused_title_text_color, + highlight_color, + border_width, + title_height, + bar_height, + font, + title_font, + bar_font, + bar_position, + bar_separator_width, + show_window_icons, + window_icons_grayscale, + container_borders, + } = theme; + macro_rules! color { + ($colorable:ident, $field:ident) => { + if let Some(color) = *$field { + window.set_color($colorable, Some(color)); + } + }; + } + color!( + ATTENTION_REQUESTED_BACKGROUND_COLOR, + attention_requested_bg_color + ); + color!(BORDER_COLOR, border_color); + color!(FOCUSED_BORDER_COLOR, focused_border_color); + color!(FOCUSED_TITLE_BACKGROUND_COLOR, focused_title_bg_color); + color!(FOCUSED_TITLE_TEXT_COLOR, focused_title_text_color); + color!(SEPARATOR_COLOR, separator_color); + color!(UNFOCUSED_TITLE_BACKGROUND_COLOR, unfocused_title_bg_color); + color!(UNFOCUSED_TITLE_TEXT_COLOR, unfocused_title_text_color); + macro_rules! size { + ($sized:ident, $field:ident) => { + if let Some(size) = *$field { + window.set_size($sized, Some(size)); + } + }; + } + size!(BORDER_WIDTH, border_width); + size!(TITLE_HEIGHT, title_height); + macro_rules! ignored { + ($($field:ident,)*) => { + $( + if $field.is_some() { + log::warn!( + "set-window-theme: `{}` is not a per-window property and is ignored", + stringify!($field).replace('_', "-"), + ); + } + )* + }; + } + ignored! { + bg_color, + bar_bg_color, + bar_status_text_color, + captured_focused_title_bg_color, + captured_unfocused_title_bg_color, + focused_inactive_title_bg_color, + focused_inactive_title_text_color, + highlight_color, + bar_height, + font, + title_font, + bar_font, + bar_position, + bar_separator_width, + show_window_icons, + window_icons_grayscale, + container_borders, + } +} + fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc) { let mut path = PathBuf::from(config_dir()); path.push(CONFIG_TOML); diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index da3a80959..09c8cc92c 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -437,6 +437,23 @@ "theme" ] }, + { + "description": "Overrides theme properties for a single window.\n\nIn window rules this action applies to the matched window. As a shortcut\naction it applies to the window focused by the seat.\n\nThe following properties are applied:\n\n- `border-color`\n- `focused-border-color`\n- `attention-requested-bg-color`\n- `focused-title-bg-color`\n- `focused-title-text-color`\n- `unfocused-title-bg-color`\n- `unfocused-title-text-color`\n- `separator-color`\n- `border-width`\n- `title-height`\n\nAll other properties are ignored and a warning is logged.\n\n`border-width` and `title-height` only affect floating windows. The borders\nand title bars of tiled windows are shared between siblings and therefore\nalways use the sizes of the global theme.\n\nThe overrides are reset when the configuration is reloaded.\n\n- Example:\n\n ```toml\n [[windows]]\n match.app-id = \"org.kde.dolphin\"\n action = { type = \"set-window-theme\", theme.border-width = 0 }\n ```\n", + "type": "object", + "properties": { + "type": { + "const": "set-window-theme" + }, + "theme": { + "description": "The theme properties to override.", + "$ref": "#/$defs/Theme" + } + }, + "required": [ + "type", + "theme" + ] + }, { "description": "Sets the log level of the compositor.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-j = { type = \"set-log-level\", level = \"debug\" }\n ```\n", "type": "object", diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index 266ace5bb..37359fe7a 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -672,6 +672,50 @@ This table is a tagged union. The variant is determined by the `type` field. It The value of this field should be a [Theme](#types-Theme). +- `set-window-theme`: + + Overrides theme properties for a single window. + + In window rules this action applies to the matched window. As a shortcut + action it applies to the window focused by the seat. + + The following properties are applied: + + - `border-color` + - `focused-border-color` + - `attention-requested-bg-color` + - `focused-title-bg-color` + - `focused-title-text-color` + - `unfocused-title-bg-color` + - `unfocused-title-text-color` + - `separator-color` + - `border-width` + - `title-height` + + All other properties are ignored and a warning is logged. + + `border-width` and `title-height` only affect floating windows. The borders + and title bars of tiled windows are shared between siblings and therefore + always use the sizes of the global theme. + + The overrides are reset when the configuration is reloaded. + + - Example: + + ```toml + [[windows]] + match.app-id = "org.kde.dolphin" + action = { type = "set-window-theme", theme.border-width = 0 } + ``` + + The table has the following fields: + + - `theme` (required): + + The theme properties to override. + + The value of this field should be a [Theme](#types-Theme). + - `set-log-level`: Sets the log level of the compositor. diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index 134b9e893..a311522b6 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -674,6 +674,46 @@ Action: description: The theme. required: true ref: Theme + set-window-theme: + description: | + Overrides theme properties for a single window. + + In window rules this action applies to the matched window. As a shortcut + action it applies to the window focused by the seat. + + The following properties are applied: + + - `border-color` + - `focused-border-color` + - `attention-requested-bg-color` + - `focused-title-bg-color` + - `focused-title-text-color` + - `unfocused-title-bg-color` + - `unfocused-title-text-color` + - `separator-color` + - `border-width` + - `title-height` + + All other properties are ignored and a warning is logged. + + `border-width` and `title-height` only affect floating windows. The borders + and title bars of tiled windows are shared between siblings and therefore + always use the sizes of the global theme. + + The overrides are reset when the configuration is reloaded. + + - Example: + + ```toml + [[windows]] + match.app-id = "org.kde.dolphin" + action = { type = "set-window-theme", theme.border-width = 0 } + ``` + fields: + theme: + description: The theme properties to override. + required: true + ref: Theme set-log-level: description: | Sets the log level of the compositor.