Repository: electron/electron
Pull request: fix: glitchy rendering and maximize behavior with different GTK themes

#### Description of Change

Fixes https://github.com/electron/electron/issues/49113.

ClientFrameViewLinux was tested against the default Adwaita theme in GNOME (and Yaru on Ubuntu). If a user installs KDE on the same system,  it can apply the Breeze GTK theme, which uses different and buggier CSS. This leads to glitchy looking windows with tiny buttons that do not maximize correctly. The problem can also affect any non-default GTK themes that the user installs.

To fix this, I looked at Chromium's implementation and updated areas where Electron diverges. Maximized frame drawing is now much closer to upstream and should be more robust to graphical glitches even with the default Adwaita theme. The changes are:

1. Do not try to draw a frame border at all when maximized, just a title bar.
2. Use rendered button sizes rather than CSS to determine sizing and spacing.
3. Use actual insets in code paths that do not specifically need restored insets.

Here is a maximized window on GNOME with the Breeze theme:

<img width="1922" height="1079" alt="image" src="https://github.com/user-attachments/assets/d62a55fd-db79-46da-8a42-6d067eb1e60e" />


<!--
Thank you for your Pull Request. Please provide a description above and review
the requirements below.

Contributors guide: https://github.com/electron/electron/blob/main/CONTRIBUTING.md

NOTE: PRS submitted without this template will be automatically closed.
-->

#### Checklist
<!-- Remove items that do not apply. For completed items, change [ ] to [x]. -->

- [ ] PR description included
- [ ] I have built and tested this PR
- [ ] `npm test` passes
- [ ] tests are [changed or added](https://github.com/electron/electron/blob/main/docs/development/testing.md)
- [ ] relevant API documentation, tutorials, and examples are updated and follow the [documentation style guide](https://github.com/electron/electron/blob/main/docs/development/style-guide.md)
- [ ] [PR release notes](https://github.com/electron/clerk/blob/main/README.md) describe the change in a way relevant to app developers, and are [capitalized, punctuated, and past tense](https://github.com/electron/clerk/blob/main/README.md#examples).

#### Release Notes

Notes: Fixed the appearance of maximized windows on GNOME in Wayland, especially when non-default GTK themes like Breeze are set.


Changed files:
filename: shell/browser/ui/views/client_frame_view_linux.cc
patch: @@ -163,7 +163,7 @@ int ClientFrameViewLinux::ResizingBorderHitTest(const gfx::Point& point) {
 gfx::Rect ClientFrameViewLinux::GetBoundsForClientView() const {
   gfx::Rect client_bounds = bounds();
   if (!frame_->IsFullscreen()) {
-    client_bounds.Inset(RestoredFrameBorderInsets());
+    client_bounds.Inset(linux_frame_layout_->FrameBorderInsets(false));
     client_bounds.Inset(
         gfx::Insets::TLBR(GetTitlebarBounds().height(), 0, 0, 0));
   }
@@ -236,13 +236,40 @@ void ClientFrameViewLinux::Layout(PassKey) {
 }
 
 void ClientFrameViewLinux::OnPaint(gfx::Canvas* canvas) {
+  if (frame_->IsFullscreen()) {
+    return;
+  }
+
+  if (frame_->IsMaximized()) {
+    // Some GTK themes (Breeze) still render shadow/border assets when
+    // maximized, and we don't need a border when maximized anyway. Chromium
+    // switches on this too: OpaqueBrowserFrameView::PaintMaximizedFrameBorder.
+    PaintMaximizedFrameBorder(canvas);
+  } else {
+    PaintRestoredFrameBorder(canvas);
+  }
+}
+
+void ClientFrameViewLinux::PaintRestoredFrameBorder(gfx::Canvas* canvas) {
   if (auto* frame_provider = linux_frame_layout_->GetFrameProvider()) {
     frame_provider->PaintWindowFrame(
         canvas, GetLocalBounds(), GetTitlebarBounds().bottom(),
         ShouldPaintAsActive(), linux_frame_layout_->GetInputInsets());
   }
 }
 
+void ClientFrameViewLinux::PaintMaximizedFrameBorder(gfx::Canvas* canvas) {
+  ui::NativeTheme::FrameTopAreaExtraParams frame_top_area;
+  frame_top_area.use_custom_frame = true;
+  frame_top_area.is_active = ShouldPaintAsActive();
+  frame_top_area.default_background_color = SK_ColorTRANSPARENT;
+  ui::NativeTheme::ExtraParams params(frame_top_area);
+  GetNativeTheme()->Paint(
+      canvas->sk_canvas(), GetColorProvider(), ui::NativeTheme::kFrameTopArea,
+      ui::NativeTheme::kNormal,
+      gfx::Rect(0, 0, width(), GetTitlebarBounds().bottom()), params);
+}
+
 void ClientFrameViewLinux::PaintAsActiveChanged() {
   UpdateThemeValues();
 }
@@ -251,23 +278,15 @@ void ClientFrameViewLinux::UpdateThemeValues() {
   gtk::GtkCssContext window_context =
       gtk::AppendCssNodeToStyleContext({}, "window.background.csd");
   gtk::GtkCssContext headerbar_context = gtk::AppendCssNodeToStyleContext(
-      {}, "headerbar.default-decoration.titlebar");
+      window_context, "headerbar.default-decoration.titlebar");
   gtk::GtkCssContext title_context =
       gtk::AppendCssNodeToStyleContext(headerbar_context, "label.title");
-  gtk::GtkCssContext button_context = gtk::AppendCssNodeToStyleContext(
-      headerbar_context, "button.image-button");
-
-  gtk_style_context_set_parent(headerbar_context, window_context);
-  gtk_style_context_set_parent(title_context, headerbar_context);
-  gtk_style_context_set_parent(button_context, headerbar_context);
-
   // ShouldPaintAsActive asks the widget, so assume active if the widget is not
   // set yet.
   if (GetWidget() != nullptr && !ShouldPaintAsActive()) {
     gtk_style_context_set_state(window_context, GTK_STATE_FLAG_BACKDROP);
     gtk_style_context_set_state(headerbar_context, GTK_STATE_FLAG_BACKDROP);
     gtk_style_context_set_state(title_context, GTK_STATE_FLAG_BACKDROP);
-    gtk_style_context_set_state(button_context, GTK_STATE_FLAG_BACKDROP);
   }
 
   theme_values_.window_border_radius =
@@ -281,10 +300,6 @@ void ClientFrameViewLinux::UpdateThemeValues() {
   theme_values_.title_color = gtk::GtkStyleContextGetColor(title_context);
   theme_values_.title_padding = gtk::GtkStyleContextGetPadding(title_context);
 
-  gtk::GtkStyleContextGet(button_context, "min-height",
-                          &theme_values_.button_min_size, nullptr);
-  theme_values_.button_padding = gtk::GtkStyleContextGetPadding(button_context);
-
   title_->SetEnabledColor(theme_values_.title_color);
 
   InvalidateLayout();
@@ -299,8 +314,9 @@ ClientFrameViewLinux::GetButtonTypeToSkip() const {
 }
 
 void ClientFrameViewLinux::UpdateButtonImages() {
-  nav_button_provider_->RedrawImages(theme_values_.button_min_size,
-                                     frame_->IsMaximized(),
+  int top_area_height = theme_values_.titlebar_min_height +
+                        theme_values_.titlebar_padding.height();
+  nav_button_provider_->RedrawImages(top_area_height, frame_->IsMaximized(),
                                      ShouldPaintAsActive());
 
   ui::NavButtonProvider::FrameButtonDisplayType skip_type =
@@ -368,7 +384,14 @@ void ClientFrameViewLinux::LayoutButtonsOnSide(
 
     button->button->SetVisible(true);
 
-    int button_width = theme_values_.button_min_size;
+    // CSS min-size/height/width is not enough to determine the actual size of
+    // the buttons, so we sample the rendered image. See Chromium's
+    // BrowserFrameViewLinuxNative::MaybeUpdateCachedFrameButtonImages.
+    int button_width =
+        nav_button_provider_
+            ->GetImage(button->type,
+                       ui::NavButtonProvider::ButtonState::kNormal)
+            .width();
     int next_button_offset =
         button_width + nav_button_provider_->GetInterNavButtonSpacing();
 
@@ -404,7 +427,7 @@ gfx::Rect ClientFrameViewLinux::GetTitlebarBounds() const {
       std::max(font_height, theme_values_.titlebar_min_height) +
       GetTitlebarContentInsets().height();
 
-  gfx::Insets decoration_insets = RestoredFrameBorderInsets();
+  gfx::Insets decoration_insets = linux_frame_layout_->FrameBorderInsets(false);
 
   // We add the inset height here, so the .Inset() that follows won't reduce it
   // to be too small.

filename: shell/browser/ui/views/client_frame_view_linux.h
patch: @@ -91,12 +91,11 @@ class ClientFrameViewLinux : public FramelessView,
 
     SkColor title_color;
     gfx::Insets title_padding;
-
-    int button_min_size;
-    gfx::Insets button_padding;
   };
 
   void PaintAsActiveChanged();
+  void PaintRestoredFrameBorder(gfx::Canvas* canvas);
+  void PaintMaximizedFrameBorder(gfx::Canvas* canvas);
 
   void UpdateThemeValues();
 

filename: shell/browser/ui/views/linux_frame_layout.cc
patch: @@ -83,6 +83,12 @@ gfx::Insets LinuxFrameLayout::RestoredFrameBorderInsets() const {
   return gfx::Insets();
 }
 
+gfx::Insets LinuxFrameLayout::FrameBorderInsets(bool restored) const {
+  return !restored && (window_->IsMaximized() || window_->IsFullscreen())
+             ? gfx::Insets()
+             : RestoredFrameBorderInsets();
+}
+
 gfx::Insets LinuxFrameLayout::GetInputInsets() const {
   return gfx::Insets(kResizeInsideBoundsSize);
 }
@@ -106,7 +112,7 @@ void LinuxFrameLayout::set_tiled(bool tiled) {
 
 gfx::Rect LinuxFrameLayout::GetWindowBounds() const {
   gfx::Rect bounds = window_->widget()->GetWindowBoundsInScreen();
-  bounds.Inset(RestoredFrameBorderInsets());
+  bounds.Inset(FrameBorderInsets(false));
   return bounds;
 }
 

filename: shell/browser/ui/views/linux_frame_layout.h
patch: @@ -44,6 +44,9 @@ class LinuxFrameLayout {
                                                   CSDStyle csd_style);
 
   // Insets from the transparent widget border to the opaque part of the window.
+  // Returns empty insets when maximized or fullscreen unless |restored| is
+  // true. Matches Chromium's OpaqueBrowserFrameViewLayout::FrameBorderInsets.
+  gfx::Insets FrameBorderInsets(bool restored) const;
   virtual gfx::Insets RestoredFrameBorderInsets() const;
   // Insets for parts of the surface that should be counted for user input.
   virtual gfx::Insets GetInputInsets() const;

filename: shell/browser/ui/views/opaque_frame_view.cc
patch: @@ -210,7 +210,7 @@ void OpaqueFrameView::OnPaint(gfx::Canvas* canvas) {
     return;
 
   const bool active = ShouldPaintAsActive();
-  const gfx::Insets border = RestoredFrameBorderInsets();
+  const gfx::Insets border = FrameBorderInsets(false);
   const bool showing_shadow = linux_frame_layout_->IsShowingShadow();
   gfx::RectF bounds_dip(GetLocalBounds());
   if (showing_shadow) {
@@ -342,9 +342,7 @@ views::Button* OpaqueFrameView::CreateButton(
 }
 
 gfx::Insets OpaqueFrameView::FrameBorderInsets(bool restored) const {
-  return !restored && IsFrameCondensed()
-             ? gfx::Insets()
-             : linux_frame_layout_->RestoredFrameBorderInsets();
+  return linux_frame_layout_->FrameBorderInsets(restored);
 }
 
 int OpaqueFrameView::FrameTopBorderThickness(bool restored) const {