From d5a4104f2e5207acdff51fb9d0522d3a2c9f39ce Mon Sep 17 00:00:00 2001 From: hacel Date: Fri, 31 Jul 2026 13:12:52 +0300 Subject: [PATCH 1/2] feat: add option to follow system color scheme Add a System appearance option that tracks GTK preference changes. Read the GTK 4.20 setting dynamically so builds retain compatibility with older GTK releases. --- src/ui/mod.rs | 63 ++++++++++++++++++++++++++++++++++++++++- src/ui/settings_page.rs | 36 +++++++++++++++++++++-- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index fb5b766..24cf05b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -22,6 +22,51 @@ use tokio::sync::Notify; type Callback = Rc; type CallbackCell = Rc>>; +const COLOR_SCHEME_PROPERTY: &str = "gtk-interface-color-scheme"; + +// Read the GTK 4.20 property dynamically for compatibility with older releases +fn system_prefers_dark(settings: >k::Settings) -> Option { + if !settings.has_property(COLOR_SCHEME_PROPERTY) { + return None; + } + + // Treat unknown values as GTK's default light scheme + glib::EnumValue::from_value(&settings.property_value(COLOR_SCHEME_PROPERTY)).and_then( + |(_, value)| match value.nick() { + "unsupported" => None, + "dark" => Some(true), + _ => Some(false), + }, + ) +} + +fn system_color_scheme() -> Option<(gtk::Settings, bool)> { + let settings = gtk::Settings::default()?; + system_prefers_dark(&settings).map(|prefers_dark| (settings, prefers_dark)) +} + +fn apply_color_scheme(window: &ApplicationWindow, prefers_dark: bool) { + window.remove_css_class("dark-theme"); + window.remove_css_class("light-theme"); + window.add_css_class(if prefers_dark { + "dark-theme" + } else { + "light-theme" + }); +} + +pub(crate) fn supports_system_color_scheme() -> bool { + system_color_scheme().is_some() +} + +pub(crate) fn apply_system_color_scheme(window: &ApplicationWindow) { + let Some((_, prefers_dark)) = system_color_scheme() else { + return; + }; + + apply_color_scheme(window, prefers_dark); +} + pub fn freq_to_band(freq: u32) -> Option<&'static str> { match freq { 2400..=2500 => Some("2.4GHz"), @@ -35,7 +80,23 @@ pub fn build_ui(app: &Application) { let win = ApplicationWindow::new(app); win.set_title(Some("")); win.set_default_size(450, 600); - win.add_css_class("dark-theme"); + + // Preserve the dark default when system preferences are unavailable + if let Some((settings, prefers_dark)) = system_color_scheme() { + win.add_css_class("system-theme"); + apply_color_scheme(&win, prefers_dark); + + let win_weak = win.downgrade(); + settings.connect_notify_local(Some(COLOR_SCHEME_PROPERTY), move |_, _| { + if let Some(window) = win_weak.upgrade() + && window.has_css_class("system-theme") + { + apply_system_color_scheme(&window); + } + }); + } else { + win.add_css_class("dark-theme"); + } let vbox = GtkBox::new(Orientation::Vertical, 0); let status = Label::new(None); diff --git a/src/ui/settings_page.rs b/src/ui/settings_page.rs index 9847829..03b410a 100644 --- a/src/ui/settings_page.rs +++ b/src/ui/settings_page.rs @@ -1,7 +1,9 @@ use gtk::prelude::*; use gtk::{Align, Box, Button, Label, Orientation}; +use crate::ui::apply_system_color_scheme; use crate::ui::header::THEMES; +use crate::ui::supports_system_color_scheme; const CUSTOM_INDEX: u32 = 0; @@ -36,7 +38,7 @@ impl SettingsPage { root.append(&title); Self::build_theme_section(&root); - Self::build_light_dark_section(&root, window); + Self::build_appearance_section(&root, window); Self { root } } @@ -96,7 +98,7 @@ impl SettingsPage { root.append(§ion); } - fn build_light_dark_section(root: >k::Box, window: >k::ApplicationWindow) { + fn build_appearance_section(root: >k::Box, window: >k::ApplicationWindow) { let section = Box::new(Orientation::Vertical, 6); let label = Label::new(Some("Appearance")); @@ -106,6 +108,10 @@ impl SettingsPage { let toggle_box = Box::new(Orientation::Horizontal, 8); + let system_btn = Button::with_label("System"); + system_btn.add_css_class("appearance-btn"); + system_btn.set_visible(supports_system_color_scheme()); + let light_btn = Button::with_label("Light"); light_btn.add_css_class("appearance-btn"); @@ -114,12 +120,30 @@ impl SettingsPage { { let window_weak = window.downgrade(); + let light_btn_clone = light_btn.clone(); + let dark_btn_clone = dark_btn.clone(); + system_btn.connect_clicked(move |btn| { + if let Some(window) = window_weak.upgrade() { + window.add_css_class("system-theme"); + apply_system_color_scheme(&window); + btn.add_css_class("appearance-active"); + light_btn_clone.remove_css_class("appearance-active"); + dark_btn_clone.remove_css_class("appearance-active"); + } + }); + } + + { + let window_weak = window.downgrade(); + let system_btn_clone = system_btn.clone(); let dark_btn_clone = dark_btn.clone(); light_btn.connect_clicked(move |btn| { if let Some(window) = window_weak.upgrade() { + window.remove_css_class("system-theme"); window.remove_css_class("dark-theme"); window.add_css_class("light-theme"); btn.add_css_class("appearance-active"); + system_btn_clone.remove_css_class("appearance-active"); dark_btn_clone.remove_css_class("appearance-active"); } }); @@ -127,23 +151,29 @@ impl SettingsPage { { let window_weak = window.downgrade(); + let system_btn_clone = system_btn.clone(); let light_btn_clone = light_btn.clone(); dark_btn.connect_clicked(move |btn| { if let Some(window) = window_weak.upgrade() { + window.remove_css_class("system-theme"); window.remove_css_class("light-theme"); window.add_css_class("dark-theme"); btn.add_css_class("appearance-active"); + system_btn_clone.remove_css_class("appearance-active"); light_btn_clone.remove_css_class("appearance-active"); } }); } - if window.has_css_class("light-theme") { + if window.has_css_class("system-theme") { + system_btn.add_css_class("appearance-active"); + } else if window.has_css_class("light-theme") { light_btn.add_css_class("appearance-active"); } else { dark_btn.add_css_class("appearance-active"); } + toggle_box.append(&system_btn); toggle_box.append(&light_btn); toggle_box.append(&dark_btn); section.append(&toggle_box); From f7c160b8d4584f1ad1c3b25c95f37e94b9bf4b3e Mon Sep 17 00:00:00 2001 From: hacel Date: Sun, 2 Aug 2026 19:05:10 +0300 Subject: [PATCH 2/2] fix: keep GTK controls consistent with app color scheme Synchronize forced light and dark modes with GTK settings so native widgets use the matching theme variant. Reset the override for System mode, inherit the active scheme in dialogs and file choosers, and apply theme colors to loading and detail icons. --- src/style.css | 2 +- src/themes/catppuccin.css | 2 +- src/themes/dracula.css | 2 +- src/themes/gruvbox.css | 2 +- src/themes/nord.css | 2 +- src/themes/tokyo.css | 2 +- src/ui/connect.rs | 4 +++ src/ui/mod.rs | 54 +++++++++++++++++++++++++++++++++------ src/ui/network_page.rs | 1 + src/ui/settings_page.rs | 7 +++-- src/ui/vpn_add_page.rs | 3 +++ src/ui/wired_page.rs | 1 + 12 files changed, 64 insertions(+), 18 deletions(-) diff --git a/src/style.css b/src/style.css index c463105..ac83217 100644 --- a/src/style.css +++ b/src/style.css @@ -230,6 +230,7 @@ label.network-poor { color: var(--error-color); } /* Loading spinner */ .loading-spinner { + color: var(--text-secondary); margin-top: 12px; margin-bottom: 12px; opacity: 0.6; @@ -454,4 +455,3 @@ popover row label { color: var(--text-secondary); font-size: 13px; } - diff --git a/src/themes/catppuccin.css b/src/themes/catppuccin.css index 5abf539..b468127 100644 --- a/src/themes/catppuccin.css +++ b/src/themes/catppuccin.css @@ -205,6 +205,7 @@ label.network-poor { color: var(--error-color); } .wifi-open { color: var(--text-primary); } .loading-spinner { + color: var(--text-secondary); margin-top: 12px; margin-bottom: 12px; opacity: 0.6; @@ -361,4 +362,3 @@ popover row label { background: var(--bg-secondary); border-color: var(--border-color-hover); } - diff --git a/src/themes/dracula.css b/src/themes/dracula.css index 363d5d7..e0bfbaa 100644 --- a/src/themes/dracula.css +++ b/src/themes/dracula.css @@ -205,6 +205,7 @@ label.network-poor { color: var(--error-color); } .wifi-open { color: var(--text-primary); } .loading-spinner { + color: var(--text-secondary); margin-top: 12px; margin-bottom: 12px; opacity: 0.6; @@ -361,4 +362,3 @@ popover row label { background: var(--bg-secondary); border-color: var(--border-color-hover); } - diff --git a/src/themes/gruvbox.css b/src/themes/gruvbox.css index 8b08bdd..51d8a71 100644 --- a/src/themes/gruvbox.css +++ b/src/themes/gruvbox.css @@ -236,6 +236,7 @@ label.network-poor { color: var(--error-color); } .wifi-open { color: var(--text-primary); } .loading-spinner { + color: var(--text-secondary); margin-top: 12px; margin-bottom: 12px; opacity: 0.6; @@ -392,4 +393,3 @@ popover row label { background: var(--bg-secondary); border-color: var(--border-color-hover); } - diff --git a/src/themes/nord.css b/src/themes/nord.css index 5ec7a32..a379098 100644 --- a/src/themes/nord.css +++ b/src/themes/nord.css @@ -205,6 +205,7 @@ label.network-poor { color: var(--error-color); } .wifi-open { color: var(--text-primary); } .loading-spinner { + color: var(--text-secondary); margin-top: 12px; margin-bottom: 12px; opacity: 0.6; @@ -361,4 +362,3 @@ popover row label { background: var(--bg-secondary); border-color: var(--border-color-hover); } - diff --git a/src/themes/tokyo.css b/src/themes/tokyo.css index b4df0be..7fbb346 100644 --- a/src/themes/tokyo.css +++ b/src/themes/tokyo.css @@ -205,6 +205,7 @@ label.network-poor { color: var(--error-color); } .wifi-open { color: var(--text-primary); } .loading-spinner { + color: var(--text-secondary); margin-top: 12px; margin-bottom: 12px; opacity: 0.6; @@ -361,4 +362,3 @@ popover row label { background: var(--bg-secondary); border-color: var(--border-color-hover); } - diff --git a/src/ui/connect.rs b/src/ui/connect.rs index bf36531..025e7fe 100644 --- a/src/ui/connect.rs +++ b/src/ui/connect.rs @@ -10,6 +10,8 @@ use nmrs::{ }; use std::rc::Rc; +use crate::ui::inherit_color_scheme; + pub fn connect_modal( nm: Rc, parent: &ApplicationWindow, @@ -45,6 +47,7 @@ fn draw_connect_modal( dialog.set_title(Some("Connect to Network")); dialog.set_transient_for(Some(parent)); dialog.set_modal(true); + inherit_color_scheme(&dialog, parent); dialog.add_css_class("diag-buttons"); let content_area = dialog.content_area(); @@ -124,6 +127,7 @@ fn draw_connect_modal( ("Open", ResponseType::Accept), ], ); + inherit_color_scheme(&file_dialog, &parent_dialog); let cert_entry = cert_entry_for_browse.clone(); file_dialog.connect_response(move |dialog, response| { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 24cf05b..868b1dd 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -45,7 +45,22 @@ fn system_color_scheme() -> Option<(gtk::Settings, bool)> { system_prefers_dark(&settings).map(|prefers_dark| (settings, prefers_dark)) } -fn apply_color_scheme(window: &ApplicationWindow, prefers_dark: bool) { +fn set_interface_color_scheme(settings: >k::Settings, prefers_dark: bool) { + settings.set_gtk_application_prefer_dark_theme(prefers_dark); + + let Some(property) = settings.find_property(COLOR_SCHEME_PROPERTY) else { + return; + }; + let Some(value) = glib::EnumClass::with_type(property.value_type()) + .and_then(|class| class.to_value_by_nick(if prefers_dark { "dark" } else { "light" })) + else { + return; + }; + + settings.set_property_from_value(COLOR_SCHEME_PROPERTY, &value); +} + +fn update_color_scheme(window: &impl IsA, prefers_dark: bool) { window.remove_css_class("dark-theme"); window.remove_css_class("light-theme"); window.add_css_class(if prefers_dark { @@ -55,16 +70,39 @@ fn apply_color_scheme(window: &ApplicationWindow, prefers_dark: bool) { }); } +pub(crate) fn inherit_color_scheme(window: &impl IsA, parent: &impl IsA) { + update_color_scheme(window, parent.has_css_class("dark-theme")); +} + +fn sync_system_color_scheme(window: &ApplicationWindow) { + let Some((settings, prefers_dark)) = system_color_scheme() else { + return; + }; + + settings.set_gtk_application_prefer_dark_theme(prefers_dark); + update_color_scheme(window, prefers_dark); +} + pub(crate) fn supports_system_color_scheme() -> bool { system_color_scheme().is_some() } +pub(crate) fn apply_color_scheme_override(window: &ApplicationWindow, prefers_dark: bool) { + if let Some(settings) = gtk::Settings::default() { + set_interface_color_scheme(&settings, prefers_dark); + } + + update_color_scheme(window, prefers_dark); +} + pub(crate) fn apply_system_color_scheme(window: &ApplicationWindow) { - let Some((_, prefers_dark)) = system_color_scheme() else { - return; - }; + if let Some(settings) = gtk::Settings::default() + && settings.has_property(COLOR_SCHEME_PROPERTY) + { + settings.reset_property(COLOR_SCHEME_PROPERTY); + } - apply_color_scheme(window, prefers_dark); + sync_system_color_scheme(window); } pub fn freq_to_band(freq: u32) -> Option<&'static str> { @@ -84,18 +122,18 @@ pub fn build_ui(app: &Application) { // Preserve the dark default when system preferences are unavailable if let Some((settings, prefers_dark)) = system_color_scheme() { win.add_css_class("system-theme"); - apply_color_scheme(&win, prefers_dark); + update_color_scheme(&win, prefers_dark); let win_weak = win.downgrade(); settings.connect_notify_local(Some(COLOR_SCHEME_PROPERTY), move |_, _| { if let Some(window) = win_weak.upgrade() && window.has_css_class("system-theme") { - apply_system_color_scheme(&window); + sync_system_color_scheme(&window); } }); } else { - win.add_css_class("dark-theme"); + apply_color_scheme_override(&win, true); } let vbox = GtkBox::new(Orientation::Vertical, 0); diff --git a/src/ui/network_page.rs b/src/ui/network_page.rs index 0dccb3f..0ec673c 100644 --- a/src/ui/network_page.rs +++ b/src/ui/network_page.rs @@ -52,6 +52,7 @@ impl NetworkPage { let header = Box::new(Orientation::Horizontal, 6); let icon = Image::from_icon_name("network-wireless-signal-excellent-symbolic"); + icon.add_css_class("network-icon"); icon.set_pixel_size(24); let title = Label::new(None); diff --git a/src/ui/settings_page.rs b/src/ui/settings_page.rs index 03b410a..958bab4 100644 --- a/src/ui/settings_page.rs +++ b/src/ui/settings_page.rs @@ -1,6 +1,7 @@ use gtk::prelude::*; use gtk::{Align, Box, Button, Label, Orientation}; +use crate::ui::apply_color_scheme_override; use crate::ui::apply_system_color_scheme; use crate::ui::header::THEMES; use crate::ui::supports_system_color_scheme; @@ -140,8 +141,7 @@ impl SettingsPage { light_btn.connect_clicked(move |btn| { if let Some(window) = window_weak.upgrade() { window.remove_css_class("system-theme"); - window.remove_css_class("dark-theme"); - window.add_css_class("light-theme"); + apply_color_scheme_override(&window, false); btn.add_css_class("appearance-active"); system_btn_clone.remove_css_class("appearance-active"); dark_btn_clone.remove_css_class("appearance-active"); @@ -156,8 +156,7 @@ impl SettingsPage { dark_btn.connect_clicked(move |btn| { if let Some(window) = window_weak.upgrade() { window.remove_css_class("system-theme"); - window.remove_css_class("light-theme"); - window.add_css_class("dark-theme"); + apply_color_scheme_override(&window, true); btn.add_css_class("appearance-active"); system_btn_clone.remove_css_class("appearance-active"); light_btn_clone.remove_css_class("appearance-active"); diff --git a/src/ui/vpn_add_page.rs b/src/ui/vpn_add_page.rs index 881fcd9..c16dec8 100644 --- a/src/ui/vpn_add_page.rs +++ b/src/ui/vpn_add_page.rs @@ -8,6 +8,8 @@ use nmrs::{NetworkManager, WireGuardConfig, WireGuardPeer}; use std::cell::RefCell; use std::rc::Rc; +use crate::ui::inherit_color_scheme; + type OnSuccessCallback = Rc>>>; pub struct VpnAddPage { @@ -293,6 +295,7 @@ impl VpnAddPage { ("Open", ResponseType::Accept), ], ); + inherit_color_scheme(&dialog, &parent); dialog.connect_response(move |dialog, response| { if response == ResponseType::Accept && let Some(file) = dialog.file() diff --git a/src/ui/wired_page.rs b/src/ui/wired_page.rs index 92b6bbf..4153085 100644 --- a/src/ui/wired_page.rs +++ b/src/ui/wired_page.rs @@ -36,6 +36,7 @@ impl WiredPage { let header = Box::new(Orientation::Horizontal, 6); let icon = Image::from_icon_name("network-wired-symbolic"); + icon.add_css_class("wired-device-icon"); icon.set_pixel_size(24); let title = Label::new(None);