Repository: zng-ui/zng (language: rust)

Issue:
Custom titlebar for GNOME+Wayland
Windows with [`chrome`](https://zng-ui.github.io/doc/zng/window/struct.Window.html#method.chrome) enabled, the default, render only a fallback title bar on GNOME+Wayland. This happens because Wayland has no server side chrome rendering, and GNOME expects apps to not just have a title bar.

The fallback title bar does not render text, we should offer a slightly better fallback, and view API so that users can detect exactly what Windowing Server is handling the window.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc050e73b..91ef78bd2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
 # Unreleased
 
+* Add better custom chrome for GNOME+Wayland.
+* Add `zng::widget::node::bind_state_init` helper.
+* Add `Window::needs_fallback_chrome` and `Window::prefer_custom_chrome` property.
+* **Breaking** Add `ChromeConfig` and related events to the view API.
+    - Note that this is only a breaking change for direct dependents of `zng-view-api`.
 
 # 0.11.6
 
diff --git a/crates/zng-app/src/running.rs b/crates/zng-app/src/running.rs
index 3819a6420..f475a0c89 100644
--- a/crates/zng-app/src/running.rs
+++ b/crates/zng-app/src/running.rs
@@ -454,6 +454,10 @@ impl<E: AppExtension> RunningApp<E> {
                 let args = RawColorsConfigChangedArgs::now(cfg);
                 self.notify_event(RAW_COLORS_CONFIG_CHANGED_EVENT.new_update(args), observer);
             }
+            Event::ChromeConfigChanged(cfg) => {
+                let args = RawChromeConfigChangedArgs::now(cfg);
+                self.notify_event(RAW_CHROME_CONFIG_CHANGED_EVENT.new_update(args), observer);
+            }
 
             // `device_events`
             Event::DeviceAdded(d_id) => {
@@ -570,6 +574,7 @@ impl<E: AppExtension> RunningApp<E> {
                     animations_config,
                     locale_config,
                     colors_config,
+                    chrome_config,
                     extensions,
                 }) => {
                     // notify immediately.
@@ -598,6 +603,7 @@ impl<E: AppExtension> RunningApp<E> {
                         animations_config,
                         locale_config,
                         colors_config,
+                        chrome_config,
                         extensions,
                     );
                     self.notify_event(VIEW_PROCESS_INITED_EVENT.new_update(args), observer);
diff --git a/crates/zng-app/src/view_process.rs b/crates/zng-app/src/view_process.rs
index 1d9795fae..ce2a24a5d 100644
--- a/crates/zng-app/src/view_process.rs
+++ b/crates/zng-app/src/view_process.rs
@@ -24,7 +24,7 @@ use zng_var::ResponderVar;
 use zng_view_api::{
     self,
     api_extension::{ApiExtensionId, ApiExtensionName, ApiExtensionPayload, ApiExtensionRecvError, ApiExtensions},
-    config::{AnimationsConfig, ColorsConfig, FontAntiAliasing, LocaleConfig, MultiClickConfig, TouchConfig},
+    config::{AnimationsConfig, ChromeConfig, ColorsConfig, FontAntiAliasing, LocaleConfig, MultiClickConfig, TouchConfig},
     dialog::{FileDialog, FileDialogResponse, MsgDialog, MsgDialogResponse},
     font::FontOptions,
     image::{ImageMaskMode, ImagePpi, ImageRequest, ImageTextureId},
@@ -594,6 +594,9 @@ event_args! {
         /// System preferred color scheme and accent color.
         pub colors_config: ColorsConfig,
 
+        /// System window chrome preferences.
+        pub chrome_config: ChromeConfig,
+
         /// API extensions implemented by the view-process.
         ///
         /// The extension IDs will stay valid for the duration of the view-process.
diff --git a/crates/zng-app/src/view_process/raw_events.rs b/crates/zng-app/src/view_process/raw_events.rs
index 2606ade08..ab145fa19 100644
--- a/crates/zng-app/src/view_process/raw_events.rs
+++ b/crates/zng-app/src/view_process/raw_events.rs
@@ -20,7 +20,9 @@ use zng_layout::unit::{DipPoint, DipSideOffsets, DipSize, Factor, PxPoint, PxRec
 use zng_txt::Txt;
 use zng_view_api::{
     api_extension::{ApiExtensionId, ApiExtensionPayload},
-    config::{AnimationsConfig, ColorsConfig, FontAntiAliasing, KeyRepeatConfig, LocaleConfig, MultiClickConfig, TouchConfig},
+    config::{
+        AnimationsConfig, ChromeConfig, ColorsConfig, FontAntiAliasing, KeyRepeatConfig, LocaleConfig, MultiClickConfig, TouchConfig,
+    },
     keyboard::{Key, KeyCode, KeyLocation, KeyState},
     mouse::{ButtonState, MouseButton, MouseScrollDelta},
     touch::{TouchPhase, TouchUpdate},
@@ -622,6 +624,19 @@ event_args! {
         }
     }
 
+    /// Arguments for the [`RAW_CHROME_CONFIG_CHANGED_EVENT`].
+    pub struct RawChromeConfigChangedArgs {
+        /// New config.
+        pub config: ChromeConfig,
+
+        ..
+
+        /// Broadcast to all widgets.
+        fn delivery_list(&self, list: &mut UpdateDeliveryList) {
+            list.search_all();
+        }
+    }
+
     /// Arguments for the [`RAW_EXTENSION_EVENT`].
     pub struct RawExtensionEventArgs {
         /// Id of the sender extension.
@@ -739,6 +754,9 @@ event! {
     /// Color scheme or accent color preference changed for a window.
     pub static RAW_COLORS_CONFIG_CHANGED_EVENT: RawColorsConfigChangedArgs;
 
+    /// System window chrome config changed.
+    pub static RAW_CHROME_CONFIG_CHANGED_EVENT: RawChromeConfigChangedArgs;
+
     /// Change in system font anti-aliasing config.
     pub static RAW_FONT_AA_CHANGED_EVENT: RawFontAaChangedArgs;
 
diff --git a/crates/zng-ext-window/src/cmd.rs b/crates/zng-ext-window/src/cmd.rs
index 8c058f583..7a4daf2a0 100644
--- a/crates/zng-ext-window/src/cmd.rs
+++ b/crates/zng-ext-window/src/cmd.rs
@@ -21,7 +21,7 @@ command! {
         name: "Close",
         info: "Close the window.",
         shortcut: [shortcut!(ALT+F4), shortcut!(CTRL+'W')],
-        icon: wgt_fn!(|_| ICONS.get("close")),
+        icon: wgt_fn!(|_| ICONS.get(["window-close", "close"])),
     };
 
     /// Represents the window **minimize** action.
@@ -29,7 +29,7 @@ command! {
         l10n!: true,
         name: "Minimize",
         info: "Minimize the window.",
-        icon: wgt_fn!(|_| ICONS.get("minimize")),
+        icon: wgt_fn!(|_| ICONS.get(["window-minimize"])),
     };
 
     /// Represents the window **maximize** action.
@@ -37,7 +37,7 @@ command! {
         l10n!: true,
         name: "Maximize",
         info: "Maximize the window.",
-        icon: wgt_fn!(|_| ICONS.get("maximize")),
+        icon: wgt_fn!(|_| ICONS.get(["window-maximize"])),
     };
 
     /// Represents the window **toggle fullscreen** action.
@@ -58,7 +58,7 @@ command! {
             };
             [a, shortcut!(ZoomToggle)]
         },
-        icon: wgt_fn!(|_| ICONS.get("fullscreen")),
+        icon: wgt_fn!(|_| ICONS.get(["window-windowed-fullscreen", "window-fullscreen", "fullscreen"])),
     };
 
     /// Represents the window **toggle fullscreen** action.
@@ -71,7 +71,7 @@ command! {
         l10n!: true,
         name: "Exclusive Fullscreen",
         info: "Toggle exclusive fullscreen mode on the window.",
-        icon: wgt_fn!(|_| ICONS.get("fullscreen")),
+        icon: wgt_fn!(|_| ICONS.get(["window-exclusive-fullscreen", "window-fullscreen", "fullscreen"])),
     };
 
     /// Represents the window **restore** action.
@@ -81,7 +81,7 @@ command! {
         l10n!: true,
         name: "Restore",
         info: "Restores the window to its previous non-minimized state or normal state.",
-        icon: wgt_fn!(|_| ICONS.get("restore")),
+        icon: wgt_fn!(|_| ICONS.get(["window-restore"])),
     };
 
     /// Represents the **close IME** action.
diff --git a/crates/zng-ext-window/src/control.rs b/crates/zng-ext-window/src/control.rs
index 6d34baf46..c061cef92 100644
--- a/crates/zng-ext-window/src/control.rs
+++ b/crates/zng-ext-window/src/control.rs
@@ -198,13 +198,15 @@ impl HeadedCtrl {
 
             let mut new_state = prev_state.clone();
 
-            if let Some(mut chrome) = self.vars.chrome().get_new() {
+            if self.vars.chrome().is_new() || WINDOWS.system_chrome().is_new() {
+                let mut chrome = self.vars.chrome().get();
+
                 if self.kiosk.is_some() && !chrome {
                     tracing::error!("window in `kiosk` mode can not show chrome");
                     chrome = false;
                 }
 
-                new_state.chrome_visible = chrome;
+                new_state.chrome_visible = chrome && !WINDOWS.system_chrome().get().needs_custom();
             }
 
             if let Some(mut req_state) = self.vars.state().get_new() {
@@ -1125,7 +1127,7 @@ impl HeadedCtrl {
             restore_state: WindowState::Normal,
             min_size: min_size.to_dip(scale_factor),
             max_size: max_size.to_dip(scale_factor),
-            chrome_visible: self.vars.chrome().get(),
+            chrome_visible: self.vars.chrome().get() && !WINDOWS.system_chrome().get().needs_custom(),
         };
 
         let window_id = WINDOW.id();
diff --git a/crates/zng-ext-window/src/service.rs b/crates/zng-ext-window/src/service.rs
index 7e1d8c635..2aa089fc3 100644
--- a/crates/zng-ext-window/src/service.rs
+++ b/crates/zng-ext-window/src/service.rs
@@ -14,8 +14,8 @@ use zng_app::{
     view_process::{
         self,
         raw_events::{
-            RAW_COLORS_CONFIG_CHANGED_EVENT, RAW_IMAGE_LOADED_EVENT, RAW_IMAGE_LOAD_ERROR_EVENT, RAW_WINDOW_CLOSE_EVENT,
-            RAW_WINDOW_CLOSE_REQUESTED_EVENT, RAW_WINDOW_FOCUS_EVENT,
+            RAW_CHROME_CONFIG_CHANGED_EVENT, RAW_COLORS_CONFIG_CHANGED_EVENT, RAW_IMAGE_LOADED_EVENT, RAW_IMAGE_LOAD_ERROR_EVENT,
+            RAW_WINDOW_CLOSE_EVENT, RAW_WINDOW_CLOSE_REQUESTED_EVENT, RAW_WINDOW_FOCUS_EVENT,
         },
         ViewImage, ViewRenderer, ViewWindowOrHeadless, VIEW_PROCESS, VIEW_PROCESS_INITED_EVENT,
     },
@@ -40,12 +40,12 @@ use zng_task::{
 use zng_txt::{formatx, Txt};
 use zng_unique_id::{IdMap, IdSet};
 use zng_var::{
-    impl_from_and_into_var, response_done_var, response_var, types::WeakArcVar, var, AnyWeakVar, ArcVar, BoxedVar, LocalVar, ResponderVar,
-    ResponseVar, Var, WeakVar,
+    impl_from_and_into_var, response_done_var, response_var, types::WeakArcVar, var, AnyWeakVar, ArcVar, BoxedVar, LocalVar,
+    ReadOnlyArcVar, ResponderVar, ResponseVar, Var, WeakVar,
 };
 use zng_view_api::{
     api_extension::{ApiExtensionId, ApiExtensionPayload},
-    config::ColorsConfig,
+    config::{ChromeConfig, ColorsConfig},
     image::ImageMaskMode,
     window::{RenderMode, WindowState},
     ViewProcessOffline,
@@ -67,6 +67,7 @@ pub(super) struct WindowsService {
     exit_on_last_close: ArcVar<bool>,
     default_render_mode: ArcVar<RenderMode>,
     parallel: ArcVar<ParallelWin>,
+    system_chrome: ArcVar<ChromeConfig>,
     root_extenders: Mutex<Vec<Box<dyn FnMut(WindowRootExtenderArgs) -> BoxedUiNode + Send>>>, // Mutex for +Sync only.
     open_nested_handlers: Mutex<Vec<Box<dyn FnMut(&mut crate::OpenNestedHandlerArgs) + Send>>>,
 
@@ -98,6 +99,7 @@ impl WindowsService {
             default_render_mode: var(RenderMode::default()),
             root_extenders: Mutex::new(vec![]),
             open_nested_handlers: Mutex::new(vec![]),
+            system_chrome: var(ChromeConfig::default()),
             parallel: var(ParallelWin::default()),
             windows: IdMap::default(),
             windows_info: IdMap::default(),
@@ -721,6 +723,17 @@ impl WINDOWS {
             .push(Box::new(move |a| extender(a).boxed()))
     }
 
+    /// Variable that tracks the OS window manager configuration for the window chrome.
+    ///
+    /// The chrome (also known as window decorations) defines the title bar, window buttons and window border. Some
+    /// window managers don't provide a native chrome, you can use this config with the [`WindowVars::chrome`] setting
+    /// in a [`register_root_extender`] to provide a custom fallback chrome.
+    ///
+    /// [`register_root_extender`]: Self::register_root_extender
+    pub fn system_chrome(&self) -> ReadOnlyArcVar<ChromeConfig> {
+        WINDOWS_SV.read().system_chrome.read_only()
+    }
+
     /// Register the closure `handler` to be called for every new window starting on the next update.
     ///
     /// The closure can use the args to inspect the new window context and optionally convert the request to a [`NestedWindowNode`].
@@ -987,8 +1000,12 @@ impl WINDOWS {
             }
         } else if let Some(args) = RAW_COLORS_CONFIG_CHANGED_EVENT.on(update) {
             WINDOWS_SV.write().latest_colors_cfg = args.config;
+        } else if let Some(args) = RAW_CHROME_CONFIG_CHANGED_EVENT.on(update) {
+            WINDOWS_SV.read().system_chrome.set(args.config);
         } else if let Some(args) = VIEW_PROCESS_INITED_EVENT.on(update) {
-            WINDOWS_SV.write().latest_colors_cfg = args.colors_config;
+            let mut wns = WINDOWS_SV.write();
+            wns.latest_colors_cfg = args.colors_config;
+            wns.system_chrome.set(args.chrome_config);
 
             // we skipped request fulfillment until this event.
             UPDATES.update(None);
diff --git a/crates/zng-ext-window/src/vars.rs b/crates/zng-ext-window/src/vars.rs
index db4b3064e..6429f8c37 100644
--- a/crates/zng-ext-window/src/vars.rs
+++ b/crates/zng-ext-window/src/vars.rs
@@ -180,6 +180,12 @@ impl WindowVars {
     /// The window chrome is the non-client area of the window, usually a border with resize handles and a title bar.
     ///
     /// The default value is `true`.
+    ///
+    /// Note that if the [`WINDOWS.system_chrome`] reports the windowing system prefers a custom chrome **and** does not
+    /// provide one the system chrome is not requested, even if this is `true`. Window widget implementers can use this to
+    /// detect when a fallback chrome must be provided.
+    ///
+    /// [`WINDOWS.system_chrome`]: crate::WINDOWS::system_chrome
     pub fn chrome(&self) -> ArcVar<bool> {
         self.0.chrome.clone()
     }
diff --git a/crates/zng-view-api/src/config.rs b/crates/zng-view-api/src/config.rs
index 81b8a6e50..117615796 100644
--- a/crates/zng-view-api/src/config.rs
+++ b/crates/zng-view-api/src/config.rs
@@ -190,3 +190,34 @@ impl Default for ColorsConfig {
 zng_var::impl_from_and_into_var! {
     fn from(some: ColorScheme) -> Option<ColorScheme>;
 }
+
+/// Window chrome (decorations) preference.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ChromeConfig {
+    /// Window manager prefers that the window renders a custom chrome.
+    ///
+    /// This is also called "Client-Side Decorations", it is `true` in GNOME+Wayland.
+    pub prefer_custom: bool,
+
+    /// If the Window manager provides a chrome.
+    ///
+    /// When this is `false` the view-process implementation may provide just a very basic fallback chrome,
+    /// if the app-process still requests system chrome.
+    pub provided: bool,
+}
+impl ChromeConfig {
+    /// If system prefers custom and does not provide chrome.
+    ///
+    /// Note that a chromeless window is not forbidden if this is `true`.
+    pub fn needs_custom(&self) -> bool {
+        self.prefer_custom && !self.provided
+    }
+}
+impl Default for ChromeConfig {
+    fn default() -> Self {
+        Self {
+            prefer_custom: false,
+            provided: true,
+        }
+    }
+}
diff --git a/crates/zng-view-api/src/types.rs b/crates/zng-view-api/src/types.rs
index 5d4c1090f..017a3a0f4 100644
--- a/crates/zng-view-api/src/types.rs
+++ b/crates/zng-view-api/src/types.rs
@@ -3,7 +3,9 @@
 use crate::{
     access::{AccessCmd, AccessNodeId},
     api_extension::{ApiExtensionId, ApiExtensionPayload, ApiExtensions},
-    config::{AnimationsConfig, ColorsConfig, FontAntiAliasing, KeyRepeatConfig, LocaleConfig, MultiClickConfig, TouchConfig},
+    config::{
+        AnimationsConfig, ChromeConfig, ColorsConfig, FontAntiAliasing, KeyRepeatConfig, LocaleConfig, MultiClickConfig, TouchConfig,
+    },
     dialog::{DialogId, FileDialogResponse, MsgDialogResponse},
     image::{ImageId, ImageLoadedData, ImagePpi},
     ipc::IpcBytes,
@@ -133,6 +135,8 @@ pub struct Inited {
     pub locale_config: LocaleConfig,
     /// System preferred color scheme and colors.
     pub colors_config: ColorsConfig,
+    /// Window chrome (decorations) preference.
+    pub chrome_config: ChromeConfig,
     /// API extensions implemented by the view-process.
     ///
     /// The extension IDs will stay valid for the duration of the view-process.
@@ -467,6 +471,8 @@ pub enum Event {
     LocaleChanged(LocaleConfig),
     /// System color scheme or colors changed.
     ColorsConfigChanged(ColorsConfig),
+    /// System window chrome (decorations) preference changed.
+    ChromeConfigChanged(ChromeConfig),
 
     /* Raw device events */
     /// Device added or installed.
diff --git a/crates/zng-view-api/src/window.rs b/crates/zng-view-api/src/window.rs
index dfa0fd1cf..94512885f 100644
--- a/crates/zng-view-api/src/window.rs
+++ b/crates/zng-view-api/src/window.rs
@@ -486,7 +486,7 @@ pub struct WindowStateAll {
 
     /// If the system provided outer-border and title-bar is visible.
     ///
-    /// This is also called the "decoration" or "chrome" of the window.
+    /// This is also called the "decoration" or "chrome" of the window. Note that the system may prefer
     pub chrome_visible: bool,
 }
 impl WindowStateAll {
diff --git a/crates/zng-view/src/config.rs b/crates/zng-view/src/config.rs
index b0d423d77..d20a733e8 100644
--- a/crates/zng-view/src/config.rs
+++ b/crates/zng-view/src/config.rs
@@ -1,5 +1,5 @@
 use zng_view_api::config::{
-    AnimationsConfig, ColorsConfig, FontAntiAliasing, KeyRepeatConfig, LocaleConfig, MultiClickConfig, TouchConfig,
+    AnimationsConfig, ChromeConfig, ColorsConfig, FontAntiAliasing, KeyRepeatConfig, LocaleConfig, MultiClickConfig, TouchConfig,
 };
 
 #[cfg(windows)]
@@ -75,6 +75,10 @@ pub fn locale_config() -> LocaleConfig {
     platform::locale_config()
 }
 
+pub fn chrome_config() -> ChromeConfig {
+    platform::chrome_config()
+}
+
 /// Return handle must be called on exit.
 #[must_use]
 pub fn spawn_listener(event_loop: crate::AppEventSender) -> Option<Box<dyn FnOnce()>> {
diff --git a/crates/zng-view/src/config/android.rs b/crates/zng-view/src/config/android.rs
index 95f602bad..2da9d76fd 100644
--- a/crates/zng-view/src/config/android.rs
+++ b/crates/zng-view/src/config/android.rs
@@ -1,6 +1,8 @@
 use crate::platform::android;
 use zng_unit::Rgba;
-use zng_view_api::config::{AnimationsConfig, ColorScheme, ColorsConfig, FontAntiAliasing, KeyRepeatConfig, MultiClickConfig, TouchConfig};
+use zng_view_api::config::{
+    AnimationsConfig, ChromeConfig, ColorScheme, ColorsConfig, FontAntiAliasing, KeyRepeatConfig, MultiClickConfig, TouchConfig,
+};
 
 pub fn font_aa() -> FontAntiAliasing {
     super::other::font_aa()
@@ -43,6 +45,13 @@ pub fn locale_config() -> zng_view_api::config::LocaleConfig {
     super::other::locale_config()
 }
 
+pub fn chrome_config() -> ChromeConfig {
+    ChromeConfig {
+        prefer_custom: false,
+        provided: false,
+    }
+}
+
 pub fn spawn_listener(l: crate::AppEventSender) -> Option<Box<dyn FnOnce()>> {
     super::other::spawn_listener(l)
 }
diff --git a/crates/zng-view/src/config/dconf.rs b/crates/zng-view/src/config/dconf.rs
index adf5d2a0f..079ed3024 100644
--- a/crates/zng-view/src/config/dconf.rs
+++ b/crates/zng-view/src/config/dconf.rs
@@ -2,7 +2,10 @@ use std::{io::BufRead as _, time::Duration};
 
 use zng_unit::{Rgba, TimeUnits as _};
 use zng_view_api::{
-    config::{AnimationsConfig, ColorScheme, ColorsConfig, FontAntiAliasing, KeyRepeatConfig, LocaleConfig, MultiClickConfig, TouchConfig},
+    config::{
+        AnimationsConfig, ChromeConfig, ColorScheme, ColorsConfig, FontAntiAliasing, KeyRepeatConfig, LocaleConfig, MultiClickConfig,
+        TouchConfig,
+    },
     Event,
 };
 
@@ -92,6 +95,19 @@ pub fn locale_config() -> LocaleConfig {
     super::other::locale_config()
 }
 
+pub fn chrome_config() -> ChromeConfig {
+    let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
+    // VSCode/Electron, it changes XDG_CURRENT_DESKTOP to "Unity" and sets ORIGINAL_XDG_CURRENT_DESKTOP,
+    // so running from VSCode gets the wrong value.
+    let is_gnome = std::env::var("ORIGINAL_XDG_CURRENT_DESKTOP")
+        .or_else(|_| std::env::var("XDG_CURRENT_DESKTOP"))
+        .map_or(false, |val| val.contains("GNOME"));
+    ChromeConfig {
+        prefer_custom: is_gnome,
+        provided: !(is_wayland && is_gnome),
+    }
+}
+
 fn on_change(key: &str, s: &crate::AppEventSender) {
     // println!("{key}"); // to discover keys, uncomment and change the config in system config app.
 
diff --git a/crates/zng-view/src/config/macos.rs b/crates/zng-view/src/config/macos.rs
index a9b43435b..25dbda94f 100644
--- a/crates/zng-view/src/config/macos.rs
+++ b/crates/zng-view/src/config/macos.rs
@@ -1,7 +1,9 @@
 use objc2_app_kit::*;
 use objc2_foundation::*;
 use zng_unit::{Rgba, TimeUnits as _};
-use zng_view_api::config::{AnimationsConfig, ColorScheme, ColorsConfig, FontAntiAliasing, KeyRepeatConfig, MultiClickConfig, TouchConfig};
+use zng_view_api::config::{
+    AnimationsConfig, ChromeConfig, ColorScheme, ColorsConfig, FontAntiAliasing, KeyRepeatConfig, MultiClickConfig, TouchConfig,
+};
 
 pub fn font_aa() -> FontAntiAliasing {
     super::other::font_aa()
@@ -73,6 +75,10 @@ pub fn locale_config() -> zng_view_api::config::LocaleConfig {
     super::other::locale_config()
 }
 
+pub fn chrome_config() -> ChromeConfig {
+    ChromeConfig::default()
+}
+
 pub fn spawn_listener(l: crate::AppEventSender) -> Option<Box<dyn FnOnce()>> {
     super::other::spawn_listener(l)
 }
diff --git a/crates/zng-view/src/config/other.rs b/crates/zng-view/src/config/other.rs
index 9f6de50a4..33b507241 100644
--- a/crates/zng-view/src/config/other.rs
+++ b/crates/zng-view/src/config/other.rs
@@ -1,6 +1,8 @@
 #![allow(unused)]
 
-use zng_view_api::config::{AnimationsConfig, ColorScheme, ColorsConfig, FontAntiAliasing, KeyRepeatConfig, MultiClickConfig, TouchConfig};
+use zng_view_api::config::{
+    AnimationsConfig, ChromeConfig, ColorScheme, ColorsConfig, FontAntiAliasing, KeyRepeatConfig, MultiClickConfig, TouchConfig,
+};
 
 pub fn font_aa() -> FontAntiAliasing {
     warn("font_aa");
@@ -32,6 +34,11 @@ pub fn colors_config() -> ColorsConfig {
     ColorsConfig::default()
 }
 
+pub fn chrome_config() -> ChromeConfig {
+    warn("chrome_config");
+    ChromeConfig::default()
+}
+
 #[cfg(not(windows))]
 pub fn locale_config() -> zng_view_api::config::LocaleConfig {
     zng_view_api::config::LocaleConfig {
diff --git a/crates/zng-view/src/config/windows.rs b/crates/zng-view/src/config/windows.rs
index 9934313c1..fe319f921 100644
--- a/crates/zng-view/src/config/windows.rs
+++ b/crates/zng-view/src/config/windows.rs
@@ -1,6 +1,7 @@
 use zng_unit::Rgba;
 use zng_view_api::config::{
-    AnimationsConfig, ColorScheme, ColorsConfig, FontAntiAliasing, KeyRepeatConfig, LocaleConfig, MultiClickConfig, TouchConfig,
+    AnimationsConfig, ChromeConfig, ColorScheme, ColorsConfig, FontAntiAliasing, KeyRepeatConfig, LocaleConfig, MultiClickConfig,
+    TouchConfig,
 };
 
 /// Create a hidden window that listens to Windows config change events.
@@ -307,6 +308,10 @@ pub fn touch_config() -> TouchConfig {
     super::other::touch_config()
 }
 
+pub fn chrome_config() -> ChromeConfig {
+    ChromeConfig::default()
+}
+
 pub fn colors_config() -> ColorsConfig {
     // source: winit
 
diff --git a/crates/zng-view/src/lib.rs b/crates/zng-view/src/lib.rs
index cda9cc87c..e3774991a 100644
--- a/crates/zng-view/src/lib.rs
+++ b/crates/zng-view/src/lib.rs
@@ -389,6 +389,7 @@ impl winit::application::ApplicationHandler<AppEvent> for App {
                 animations_config: config::animations_config(),
                 locale_config: config::locale_config(),
                 colors_config: config::colors_config(),
+                chrome_config: config::chrome_config(),
                 extensions: self.exts.api_extensions(),
             }));
 
@@ -1610,6 +1611,7 @@ impl Api for App {
             animations_config: config::animations_config(),
             locale_config: config::locale_config(),
             colors_config: config::colors_config(),
+            chrome_config: config::chrome_config(),
             extensions: self.exts.api_extensions(),
         }));
     }
diff --git a/crates/zng-wgt-window/Cargo.toml b/crates/zng-wgt-window/Cargo.toml
index 8a9eb49bc..6d617525a 100644
--- a/crates/zng-wgt-window/Cargo.toml
+++ b/crates/zng-wgt-window/Cargo.toml
@@ -33,10 +33,16 @@ zng-color = { path = "../zng-color", version = "0.3.10" }
 zng-wgt-input = { path = "../zng-wgt-input", version = "0.2.32" }
 zng-wgt-layer = { path = "../zng-wgt-layer", version = "0.3.22" }
 
+# used only by fallback_chrome
+zng-wgt-size-offset = { path = "../zng-wgt-size-offset", version = "0.2.32" }
+zng-wgt-stack = { path = "../zng-wgt-stack", version = "0.3.22" }
+zng-wgt-button = { path = "../zng-wgt-button", version = "0.4.12" }
+
 serde = { version = "1.0", features = ["derive"] }
 paste = "1.0"
 parking_lot = "0.12"
 euclid = "0.22"
+tracing = "0.1"
 
 [package.metadata.docs.rs]
 all-features = true
diff --git a/crates/zng-wgt-window/src/fallback_chrome.rs b/crates/zng-wgt-window/src/fallback_chrome.rs
new file mode 100644
index 000000000..c6d383ad3
--- /dev/null
+++ b/crates/zng-wgt-window/src/fallback_chrome.rs
@@ -0,0 +1,167 @@
+use zng_ext_input::mouse::*;
+use zng_ext_window::{cmd::*, *};
+use zng_wgt::{prelude::*, *};
+use zng_wgt_button::Button;
+use zng_wgt_container::*;
+use zng_wgt_fill::*;
+use zng_wgt_input::{mouse::*, *};
+use zng_wgt_stack::{Stack, StackDirection};
+use zng_wgt_text::Text;
+
+/// Custom window chrome adorner used when the window manager does not provide one.
+///
+/// You also must set a padding of `5` for maximized window and `(28 + 5, 5, 5, 5)` for normal window.
+pub fn fallback_chrome() -> impl UiNode {
+    let vars = WINDOW.vars();
+    let can_move = vars.state().map(|s| matches!(s, WindowState::Normal | WindowState::Maximized));
+    let win_id = WINDOW.id();
+    let title = Text! {
+        txt = vars.title();
+        align = Align::FILL_TOP;
+        background_color = light_dark(colors::WHITE, colors::BLACK);
+        zng_wgt_size_offset::height = 28;
+        txt_align = Align::CENTER;
+
+        child_right = Stack! {
+            direction = StackDirection::left_to_right();
+            zng_wgt_button::style_fn = zng_wgt_button::LightStyle! {
+                corner_radius = 0;
+                zng_wgt_button::cmd_child_fn = wgt_fn!(|cmd: Command| {
+                    presenter((), cmd.icon().map(move |ico| wgt_fn!(ico, |_| {
+                        let ico = ico(());
+                        if ico.is_nil() {
+                            // fallback to Unicode symbol
+                            let cmd = cmd.scoped(zng_app::event::CommandScope::App);
+                            let (symbol, size) = if cmd == RESTORE_CMD {
+                                ("🗗", 9)
+                            } else if cmd == MINIMIZE_CMD {
+                                ("🗕", 9)
+                            } else if cmd == MAXIMIZE_CMD {
+                                ("🗖", 9)
+                            } else if cmd == CLOSE_CMD {
+                                ("🗙", 14)
+                            } else {
+                                unreachable!("{cmd:?} what")
+                            };
+                            Text! {
+                                font_family = "Noto Sans Symbols 2";
+                                font_size = size.pt();
+                                txt = symbol;
+                            }.boxed()
+                        } else {
+                            ico
+                        }
+                    })))
+                });
+            };
+            children = ui_vec![
+                Button! {
+                    cmd = MINIMIZE_CMD.scoped(win_id);
+                },
+                Button! {
+                    cmd = MAXIMIZE_CMD.scoped(win_id);
+                    when #is_disabled {
+                        visibility = false;
+                    }
+                },
+                Button! {
+                    cmd = RESTORE_CMD.scoped(win_id);
+                    when #is_disabled {
+                        visibility = false;
+                    }
+                },
+                Button! {
+                    cmd = CLOSE_CMD.scoped(win_id);
+                },
+            ];
+        }, 0;
+
+        when *#{can_move.clone()} {
+            cursor = CursorIcon::Move;
+        }
+        mouse::on_mouse_down = hn!(|args: &MouseInputArgs| {
+            if args.is_primary() && can_move.get() && args.target.widget_id() == WIDGET.id() {
+                DRAG_MOVE_RESIZE_CMD.scoped(WINDOW.id()).notify();
+            }
+        });
+
+        gesture::on_context_click = hn!(|args: &gesture::ClickArgs| {
+            if matches!(WINDOW.vars().state().get(), WindowState::Normal | WindowState::Maximized) && args.target.widget_id() == WIDGET.id() {
+                if let Some(p) = args.position() {
+                    OPEN_TITLE_BAR_CONTEXT_MENU_CMD.scoped(WINDOW.id()).notify_param(p);
+                }
+            }
+        });
+    };
+
+    use zng_ext_window::cmd::ResizeDirection as RD;
+
+    fn resize_direction(wgt_pos: PxPoint) -> Option<RD> {
+        let p = wgt_pos;
+        let s = WIDGET.bounds().inner_size();
+        let b = WIDGET.border().offsets();
+        let corner_b = b * FactorSideOffsets::from(3.fct());
+
+        if p.x <= b.left {
+            if p.y <= corner_b.top {
+                Some(RD::NorthWest)
+            } else if p.y >= s.height - corner_b.bottom {
+                Some(RD::SouthWest)
+            } else {
+                Some(RD::West)
+            }
+        } else if p.x >= s.width - b.right {
+            if p.y <= corner_b.top {
+                Some(RD::NorthEast)
+            } else if p.y >= s.height - corner_b.bottom {
+                Some(RD::SouthEast)
+            } else {
+                Some(RD::East)
+            }
+        } else if p.y <= b.top {
+            if p.x <= corner_b.left {
+                Some(RD::NorthWest)
+            } else if p.x >= s.width - corner_b.right {
+                Some(RD::NorthEast)
+            } else {
+                Some(RD::North)
+            }
+        } else if p.y >= s.height - b.bottom {
+            if p.x <= corner_b.left {
+                Some(RD::SouthWest)
+            } else if p.x >= s.width - corner_b.right {
+                Some(RD::SouthEast)
+            } else {
+                Some(RD::South)
+            }
+        } else {
+            None
+        }
+    }
+
+    let cursor = var(CursorSource::Hidden);
+
+    Container! {
+        hit_test_mode = HitTestMode::Detailed;
+
+        child = title;
+
+        when matches!(#{vars.state()}, WindowState::Normal) {
+            border = 5, light_dark(colors::WHITE, colors::BLACK).rgba().map_into();
+            cursor = cursor.clone();
+            on_mouse_move = hn!(|args: &MouseMoveArgs| {
+                cursor.set(match args.position_wgt().and_then(resize_direction) {
+                    Some(d) => CursorIcon::from(d).into(),
+                    None => CursorSource::Hidden,
+                });
+            });
+            on_mouse_down = hn!(|args: &MouseInputArgs| {
+                if args.is_primary() {
+                    if let Some(d) = args.position_wgt().and_then(resize_direction) {
+                        DRAG_MOVE_RESIZE_CMD.scoped(WINDOW.id()).notify_param(d);
+                    }
+                }
+            });
+        }
+    }
+}
diff --git a/crates/zng-wgt-window/src/lib.rs b/crates/zng-wgt-window/src/lib.rs
index d2d47e66e..1fc7bdca8 100644
--- a/crates/zng-wgt-window/src/lib.rs
+++ b/crates/zng-wgt-window/src/lib.rs
@@ -9,6 +9,9 @@
 #![warn(unused_extern_crates)]
 #![warn(missing_docs)]
 
+// used by fallback_chrome
+zng_wgt::enable_widget_macros!();
+
 use zng_ext_input::focus::{DirectionalNav, FocusScopeOnFocus, TabNav};
 use zng_ext_window::{
     FrameImageReadyArgs, HeadlessMonitor, RenderMode, StartPosition, WINDOW_Ext as _, WindowChangedArgs, WindowCloseArgs,
@@ -29,6 +32,9 @@ mod window_properties;
 #[allow(ambiguous_glob_reexports)] // we override `font_size`.
 pub use self::window_properties::*;
 
+mod fallback_chrome;
+pub use fallback_chrome::fallback_chrome;
+
 /// A window container.
 ///
 /// The instance type is [`WindowRoot`], it can be given to the [`WINDOWS`](zng_ext_window::WINDOWS) service
@@ -76,6 +82,26 @@ impl Window {
                 focus_scope_behavior = FocusScopeOnFocus::Widget;
                 font_size = FONT_SIZE_VAR.map(|f| f.clone() * 1.5.fct()).boxed();
             }
+
+            when #needs_fallback_chrome {
+                custom_chrome_adorner_fn = wgt_fn!(|_| {
+                    fallback_chrome()
+                });
+                padding = ContextualizedVar::new(|| {
+                    let vars = WINDOW.vars();
+                    expr_var! {
+                        let title_padding = SideOffsets::new(28, 0, 0, 0);
+                        let chrome_padding = if matches!(#{vars.state()}, zng_ext_window::WindowState::Maximized) {
+                            title_padding
+                        } else {
+                            title_padding + SideOffsets::new_all(5)
+                        };
+                        // safe_padding is 0 in GNOME+Wayland, but better be safe :D
+                        let safe_padding = SideOffsets::from(*#{vars.safe_padding()});
+                        chrome_padding + safe_padding
+                    }
+                });
+            }
         }
 
         self.widget_builder().push_build_action(|wgt| {
diff --git a/crates/zng-wgt-window/src/window_properties.rs b/crates/zng-wgt-window/src/window_properties.rs
index 405c7f1bd..1bae8c01d 100644
--- a/crates/zng-wgt-window/src/window_properties.rs
+++ b/crates/zng-wgt-window/src/window_properties.rs
@@ -3,11 +3,12 @@ use std::time::Duration;
 use zng_ext_config::{AnyConfig as _, ConfigKey, ConfigStatus, ConfigValue, CONFIG};
 use zng_ext_window::{
     AutoSize, FrameCaptureMode, MonitorQuery, WINDOW_Ext as _, WindowButton, WindowIcon, WindowLoadingHandle, WindowState, WindowVars,
-    MONITORS, WINDOW_LOAD_EVENT,
+    MONITORS, WINDOWS, WINDOW_LOAD_EVENT,
 };
 use zng_wgt::prelude::*;
 
 use serde::{Deserialize, Serialize};
+use zng_wgt_layer::adorner_fn;
 
 use super::Window;
 
@@ -483,3 +484,44 @@ pub fn force_mobile(child: impl UiNode, is_mobile: impl IntoVar<bool>) -> impl U
 pub fn is_mobile(child: impl UiNode, is_mobile: impl IntoVar<bool>) -> impl UiNode {
     zng_wgt::node::bind_state(child, IS_MOBILE_VAR, is_mobile)
 }
+
+/// Gets if [`chrome`] is `true`, [`state`] is not fullscreen but [`WINDOWS.system_chrome`] reports the system does not provide window decorations.
+///
+/// [`chrome`]: fn@chrome
+/// [`state`]: fn@state
+/// [`WINDOWS.system_chrome`]: WINDOWS::system_chrome
+#[property(EVENT, default(state_var()), widget_impl(Window))]
+pub fn needs_fallback_chrome(child: impl UiNode, needs: impl IntoVar<bool>) -> impl UiNode {
+    zng_wgt::node::bind_state_init(
+        child,
+        || {
+            let vars = WINDOW.vars();
+            expr_var! {
+                *#{vars.chrome()} && #{WINDOWS.system_chrome()}.needs_custom() && !#{vars.state()}.is_fullscreen()
+            }
+        },
+        needs,
+    )
+}
+
+/// Gets if [`WINDOWS.system_chrome`] prefers custom chrome.
+///
+/// Note that you must set [`chrome`] to `false` when using this to provide a custom chrome.
+///
+/// [`chrome`]: fn@chrome
+/// [`WINDOWS.system_chrome`]: WINDOWS::system_chrome
+#[property(EVENT, default(state_var()), widget_impl(Window))]
+pub fn prefer_custom_chrome(child: impl UiNode, prefer: impl IntoVar<bool>) -> impl UiNode {
+    zng_wgt::node::bind_state(child, WINDOWS.system_chrome().map(|c| c.prefer_custom), prefer)
+}
+
+/// Adorner property specific for custom chrome overlays.
+///
+/// This property behaves exactly like [`adorner_fn`]. Using it instead of adorner frees the adorner property
+/// for other usage in the window instance or in derived window types.
+///
+/// [`adorner_fn`]: fn@adorner_fn
+#[property(FILL, default(WidgetFn::nil()), widget_impl(Window))]
+pub fn custom_chrome_adorner_fn(child: impl UiNode, custom_chrome: impl IntoVar<WidgetFn<()>>) -> impl UiNode {
+    adorner_fn(child, custom_chrome)
+}
diff --git a/crates/zng-wgt/src/node.rs b/crates/zng-wgt/src/node.rs
index 858938316..17a9152c7 100644
--- a/crates/zng-wgt/src/node.rs
+++ b/crates/zng-wgt/src/node.rs
@@ -1326,6 +1326,36 @@ pub fn bind_state<T: VarValue>(child: impl UiNode, source: impl IntoVar<T>, stat
     })
 }
 
+/// Helper for declaring state properties that are controlled by a variable.
+///
+/// On init the `state` closure is called to provide a variable, the variable is set to `source` and bound to it,
+/// you can use this to create state properties that map from a context variable or to create composite properties
+/// that merge other state properties.
+pub fn bind_state_init<T, V>(child: impl UiNode, source: impl Fn() -> V + Send + 'static, state: impl IntoVar<T>) -> impl UiNode
+where
+    T: VarValue,
+    V: Var<T>,
+{
+    let state = state.into_var();
+    let mut _source_var = None;
+    let mut _binding = VarHandle::dummy();
+
+    match_node(child, move |_, op| match op {
+        UiNodeOp::Init => {
+            validate_getter_var(&state);
+            let source = source();
+            let _ = state.set_from(&source);
+            _binding = source.bind(&state);
+            _source_var = Some(source);
+        }
+        UiNodeOp::Deinit => {
+            _binding = VarHandle::dummy();
+            _source_var = None;
+        }
+        _ => {}
+    })
+}
+
 /// Helper for declaring state properties that are controlled by values in the widget state map.
 ///
 /// The `predicate` closure is called with the widget state on init and every update, if the returned value changes the `state`
diff --git a/crates/zng/src/lib.rs b/crates/zng/src/lib.rs
index 902844bca..4af4f1806 100644
--- a/crates/zng/src/lib.rs
+++ b/crates/zng/src/lib.rs
@@ -794,9 +794,9 @@ mod __prelude_wgt {
     };
 
     pub use zng_wgt::node::{
-        bind_state, border_node, command_property, event_property, event_state, event_state2, event_state3, event_state4, fill_node,
-        list_presenter, presenter, presenter_opt, widget_state_get_state, widget_state_is_state, with_context_blend, with_context_local,
-        with_context_local_init, with_context_var, with_context_var_init, with_widget_state, with_widget_state_modify,
+        bind_state, bind_state_init, border_node, command_property, event_property, event_state, event_state2, event_state3, event_state4,
+        fill_node, list_presenter, presenter, presenter_opt, widget_state_get_state, widget_state_is_state, with_context_blend,
+        with_context_local, with_context_local_init, with_context_var, with_context_var_init, with_widget_state, with_widget_state_modify,
     };
 
     pub use zng_ext_window::WidgetInfoBuilderImeArea as _;
diff --git a/crates/zng/src/widget.rs b/crates/zng/src/widget.rs
index 010420e61..a94e176cf 100644
--- a/crates/zng/src/widget.rs
+++ b/crates/zng/src/widget.rs
@@ -221,8 +221,8 @@ pub mod node {
     };
 
     pub use zng_wgt::node::{
-        bind_state, border_node, event_state, event_state2, event_state3, event_state4, fill_node, interactive_node, list_presenter,
-        presenter, presenter_opt, widget_state_get_state, widget_state_is_state, with_context_blend, with_context_local,
+        bind_state, bind_state_init, border_node, event_state, event_state2, event_state3, event_state4, fill_node, interactive_node,
+        list_presenter, presenter, presenter_opt, widget_state_get_state, widget_state_is_state, with_context_blend, with_context_local,
         with_context_local_init, with_context_var, with_context_var_init, with_index_len_node, with_index_node, with_rev_index_node,
         with_widget_state, with_widget_state_modify,
     };
@@ -781,6 +781,9 @@ pub use zng_app::widget::easing;
 /// based on the prefix, the default is `var(T::default())`, this can be overwritten just by setting the default,
 /// it is not possible to declare a getter property without default.
 ///
+/// Note that if a property is used in `when` condition without being set and without default value the when block is discarded on
+/// widget build. If you are implementing a getter property that is not named using the prefixes listed above you must set `default(var(T::default())`.
+///
 /// # Generics
 ///
 /// Apart from the `impl` generics of inputs and child, there is some support for named generic types, only one named generic is allowed


Test patch: