| // Copyright 2021 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "ui/gtk/window_frame_provider_gtk.h" |
| |
| #include "base/logging.h" |
| #include "base/numerics/safe_conversions.h" |
| #include "third_party/skia/include/core/SkRRect.h" |
| #include "ui/base/ui_base_features.h" |
| #include "ui/gfx/canvas.h" |
| #include "ui/gfx/geometry/insets.h" |
| #include "ui/gfx/geometry/rect.h" |
| #include "ui/gfx/geometry/skia_conversions.h" |
| #include "ui/gfx/scoped_canvas.h" |
| #include "ui/gtk/gtk_compat.h" |
| #include "ui/gtk/gtk_util.h" |
| #include "ui/native_theme/native_theme.h" |
| |
| namespace gtk { |
| |
| namespace { |
| |
| // The maximum reasonable size of the frame edges in DIPs. If a GTK theme draws |
| // window decorations larger than this, they will be clipped. |
| constexpr int kMaxFrameSizeDip = 64; |
| |
| // The maximum reasonable radius of the frame top corners in DIPs. If this |
| // limit is exceeded, the corners will be drawn correctly, but the compositor |
| // will get an incorrect hint as to which pixels are fully opaque. |
| constexpr int kMaxCornerRadiusDip = 32; |
| |
| GtkCssContext WindowContext(bool solid_frame, bool tiled, bool focused) { |
| std::string selector = "window.background."; |
| selector += solid_frame ? "solid-csd" : "csd"; |
| if (tiled) { |
| selector += ".tiled"; |
| } |
| if (!focused) { |
| selector += ":inactive"; |
| } |
| return AppendCssNodeToStyleContext({}, selector); |
| } |
| |
| GtkCssContext DecorationContext(bool solid_frame, bool tiled, bool focused) { |
| auto context = WindowContext(solid_frame, tiled, focused); |
| // GTK4 renders the decoration directly on the window. |
| if (!GtkCheckVersion(4)) { |
| context = AppendCssNodeToStyleContext(context, "decoration"); |
| } |
| if (!focused) { |
| gtk_style_context_set_state(context, GTK_STATE_FLAG_BACKDROP); |
| } |
| |
| // The web contents is rendered after the frame border, so remove bottom |
| // rounded corners otherwise their borders would get covered up. |
| ApplyCssToContext(context, R"(* { |
| border-bottom-left-radius: 0; |
| border-bottom-right-radius: 0; |
| })"); |
| |
| return context; |
| } |
| |
| GtkCssContext HeaderContext(bool solid_frame, bool tiled, bool focused) { |
| auto context = WindowContext(solid_frame, tiled, focused); |
| context = |
| AppendCssNodeToStyleContext(context, "headerbar.header-bar.titlebar"); |
| if (!focused) { |
| gtk_style_context_set_state(context, GTK_STATE_FLAG_BACKDROP); |
| } |
| if (features::IsChromeRefresh2023()) { |
| ApplyCssToContext(context, "* { border-bottom-style: none; }"); |
| } |
| return context; |
| } |
| |
| SkBitmap PaintBitmap(const gfx::Size& bitmap_size, |
| const gfx::RectF& render_bounds, |
| GtkCssContext context, |
| float scale) { |
| SkBitmap bitmap; |
| bitmap.allocN32Pixels(bitmap_size.width(), bitmap_size.height()); |
| bitmap.eraseColor(SK_ColorTRANSPARENT); |
| |
| CairoSurface surface(bitmap); |
| cairo_t* cr = surface.cairo(); |
| |
| double opacity = GetOpacityFromContext(context); |
| if (opacity < 1) { |
| cairo_push_group(cr); |
| } |
| |
| cairo_scale(cr, scale, scale); |
| gtk_render_background(context, cr, render_bounds.x(), render_bounds.y(), |
| render_bounds.width(), render_bounds.height()); |
| gtk_render_frame(context, cr, render_bounds.x(), render_bounds.y(), |
| render_bounds.width(), render_bounds.height()); |
| |
| if (opacity < 1) { |
| cairo_pop_group_to_source(cr); |
| cairo_set_operator(cr, CAIRO_OPERATOR_OVER); |
| cairo_paint_with_alpha(cr, opacity); |
| } |
| |
| bitmap.setImmutable(); |
| return bitmap; |
| } |
| |
| SkBitmap PaintHeaderbar(const gfx::Size& size, |
| GtkCssContext context, |
| float scale) { |
| gfx::RectF tabstrip_bounds_dip(0, 0, size.width() / scale, |
| size.height() / scale); |
| return PaintBitmap(size, tabstrip_bounds_dip, context, scale); |
| } |
| |
| int ComputeTopCornerRadius() { |
| // In GTK4, there's no way to directly obtain CSS values for a context, so we |
| // need to experimentally determine the corner radius by rendering a sample. |
| // Additionally, in GTK4, the headerbar corners get clipped by the window |
| // rather than the headerbar having its own rounded corners. |
| auto context = GtkCheckVersion(4) ? DecorationContext(false, false, false) |
| : HeaderContext(false, false, false); |
| ApplyCssToContext(context, R"(window, headerbar { |
| background-image: none; |
| background-color: black; |
| box-shadow: none; |
| border: none; |
| border-bottom-left-radius: 0; |
| border-bottom-right-radius: 0; |
| border-top-right-radius: 0; |
| })"); |
| gfx::Size size_dip{kMaxCornerRadiusDip, kMaxCornerRadiusDip}; |
| auto bitmap = |
| GtkCheckVersion(4) |
| ? PaintBitmap(size_dip, {{0, 0}, gfx::SizeF(size_dip)}, context, 1) |
| : PaintHeaderbar(size_dip, context, 1); |
| DCHECK_EQ(bitmap.width(), bitmap.height()); |
| for (int i = 0; i < bitmap.width(); ++i) { |
| if (SkColorGetA(bitmap.getColor(0, i)) == 255 && |
| SkColorGetA(bitmap.getColor(i, 0)) == 255) { |
| return i; |
| } |
| } |
| return bitmap.width(); |
| } |
| |
| // Returns true iff any part of the header is transparent (even a single pixel). |
| // This is used as an optimization hint to the compositor so that it doesn't |
| // have to composite behind opaque regions. The consequence of a false-negative |
| // is rendering artifacts, but the consequence of a false-positive is only a |
| // slight performance penalty, so this function is intentionally conservative |
| // in deciding if the header is translucent. |
| bool HeaderIsTranslucent() { |
| // The arbitrary square size to render a sample header. |
| constexpr int kHeaderSize = 32; |
| auto context = HeaderContext(false, false, false); |
| double opacity = GetOpacityFromContext(context); |
| if (opacity < 1.0) { |
| return true; |
| } |
| ApplyCssToContext(context, R"(window, headerbar { |
| box-shadow: none; |
| border: none; |
| border-radius: 0; |
| })"); |
| gfx::Size size_dip{kHeaderSize, kHeaderSize}; |
| auto bitmap = PaintHeaderbar(size_dip, context, 1); |
| for (int x = 0; x < kHeaderSize; x++) { |
| for (int y = 0; y < kHeaderSize; y++) { |
| if (SkColorGetA(bitmap.getColor(x, y)) != SK_AlphaOPAQUE) { |
| return true; |
| } |
| } |
| } |
| return false; |
| } |
| |
| } // namespace |
| |
| WindowFrameProviderGtk::Asset::Asset() = default; |
| |
| WindowFrameProviderGtk::Asset::Asset(const WindowFrameProviderGtk::Asset& src) { |
| CloneFrom(src); |
| } |
| |
| WindowFrameProviderGtk::Asset& WindowFrameProviderGtk::Asset::operator=( |
| const WindowFrameProviderGtk::Asset& src) { |
| CloneFrom(src); |
| return *this; |
| } |
| |
| WindowFrameProviderGtk::Asset::~Asset() = default; |
| |
| void WindowFrameProviderGtk::Asset::CloneFrom( |
| const WindowFrameProviderGtk::Asset& src) { |
| valid = src.valid; |
| if (!valid) { |
| return; |
| } |
| |
| frame_size_px = src.frame_size_px; |
| frame_thickness_px = src.frame_thickness_px; |
| focused_bitmap = src.focused_bitmap; |
| unfocused_bitmap = src.unfocused_bitmap; |
| } |
| |
| WindowFrameProviderGtk::WindowFrameProviderGtk(bool solid_frame, bool tiled) |
| : solid_frame_(solid_frame), tiled_(tiled) { |
| GtkSettings* settings = gtk_settings_get_default(); |
| // Unretained() is safe since WindowFrameProviderGtk will own the signals. |
| auto callback = base::BindRepeating(&WindowFrameProviderGtk::OnThemeChanged, |
| base::Unretained(this)); |
| theme_name_signal_ = ScopedGSignal(settings, "notify::gtk-theme-name", |
| callback, G_CONNECT_AFTER); |
| prefer_dark_signal_ = |
| ScopedGSignal(settings, "notify::gtk-application-prefer-dark-theme", |
| callback, G_CONNECT_AFTER); |
| } |
| |
| WindowFrameProviderGtk::~WindowFrameProviderGtk() = default; |
| |
| int WindowFrameProviderGtk::GetTopCornerRadiusDip() { |
| MaybeUpdateBitmaps(GetDeviceScaleFactor()); |
| return top_corner_radius_dip_; |
| } |
| |
| bool WindowFrameProviderGtk::IsTopFrameTranslucent() { |
| MaybeUpdateBitmaps(GetDeviceScaleFactor()); |
| return top_frame_is_translucent_; |
| } |
| |
| gfx::Insets WindowFrameProviderGtk::GetFrameThicknessDip() { |
| MaybeUpdateBitmaps(GetDeviceScaleFactor()); |
| return frame_thickness_dip_; |
| } |
| |
| void WindowFrameProviderGtk::PaintWindowFrame(gfx::Canvas* canvas, |
| const gfx::Rect& rect_dip, |
| int top_area_height_dip, |
| bool focused, |
| const gfx::Insets& input_insets) { |
| gfx::ScopedCanvas scoped_canvas(canvas); |
| float scale = canvas->UndoDeviceScaleFactor(); |
| |
| MaybeUpdateBitmaps(scale); |
| |
| const auto& asset = assets_[scale]; |
| DCHECK(asset.valid); |
| |
| const auto input_insets_px = gfx::ScaleToRoundedInsets(input_insets, scale); |
| auto effective_frame_thickness_px = asset.frame_thickness_px; |
| effective_frame_thickness_px.SetToMax(input_insets_px); |
| |
| auto client_bounds_px = gfx::ScaleToRoundedRect(rect_dip, scale); |
| client_bounds_px.Inset(effective_frame_thickness_px); |
| |
| gfx::Rect src_rect(gfx::Size(BitmapSizePx(asset), BitmapSizePx(asset))); |
| src_rect.Inset(gfx::Insets(asset.frame_size_px) - |
| effective_frame_thickness_px); |
| |
| auto corner_w = std::min(asset.frame_size_px, client_bounds_px.width() / 2); |
| auto corner_h = std::min(asset.frame_size_px, client_bounds_px.height() / 2); |
| auto edge_w = client_bounds_px.width() - 2 * corner_w; |
| auto edge_h = client_bounds_px.height() - 2 * corner_h; |
| |
| auto corner_insets = |
| effective_frame_thickness_px + gfx::Insets::VH(corner_h, corner_w); |
| |
| auto image = gfx::ImageSkia::CreateFrom1xBitmap( |
| focused ? asset.focused_bitmap : asset.unfocused_bitmap); |
| |
| auto draw_image = [&](int src_x, int src_y, int src_w, int src_h, int dst_x, |
| int dst_y, int dst_w, int dst_h) { |
| if (src_w <= 0 || src_h <= 0 || dst_w <= 0 || dst_h <= 0) { |
| return; |
| } |
| canvas->DrawImageInt(image, src_x, src_y, src_w, src_h, dst_x, dst_y, dst_w, |
| dst_h, false); |
| }; |
| |
| // Top left corner |
| draw_image(src_rect.x(), src_rect.y(), corner_insets.left(), |
| corner_insets.top(), 0, 0, corner_insets.left(), |
| corner_insets.top()); |
| // Top right corner |
| draw_image(BitmapSizePx(asset) - asset.frame_size_px - corner_w, src_rect.y(), |
| corner_insets.right(), corner_insets.top(), |
| client_bounds_px.right() - corner_w, 0, corner_insets.right(), |
| corner_insets.top()); |
| // Bottom left corner |
| draw_image(src_rect.x(), BitmapSizePx(asset) - asset.frame_size_px - corner_h, |
| corner_insets.left(), corner_insets.bottom(), 0, |
| client_bounds_px.bottom() - corner_h, corner_insets.left(), |
| corner_insets.bottom()); |
| // Bottom right corner |
| draw_image(BitmapSizePx(asset) - asset.frame_size_px - corner_w, |
| BitmapSizePx(asset) - asset.frame_size_px - corner_h, |
| corner_insets.right(), corner_insets.bottom(), |
| client_bounds_px.right() - corner_w, |
| client_bounds_px.bottom() - corner_h, corner_insets.right(), |
| corner_insets.bottom()); |
| // Top edge |
| draw_image(2 * asset.frame_size_px, src_rect.y(), 1, |
| effective_frame_thickness_px.top(), corner_insets.left(), 0, |
| edge_w, effective_frame_thickness_px.top()); |
| // Left edge |
| draw_image(src_rect.x(), 2 * asset.frame_size_px, |
| effective_frame_thickness_px.left(), 1, 0, corner_insets.top(), |
| effective_frame_thickness_px.left(), edge_h); |
| // Bottom edge |
| draw_image(2 * asset.frame_size_px, BitmapSizePx(asset) - asset.frame_size_px, |
| 1, effective_frame_thickness_px.bottom(), corner_insets.left(), |
| client_bounds_px.bottom(), edge_w, |
| effective_frame_thickness_px.bottom()); |
| // Right edge |
| draw_image(BitmapSizePx(asset) - asset.frame_size_px, 2 * asset.frame_size_px, |
| effective_frame_thickness_px.right(), 1, client_bounds_px.right(), |
| corner_insets.top(), effective_frame_thickness_px.right(), edge_h); |
| |
| const int top_area_bottom_dip = rect_dip.y() + top_area_height_dip; |
| const int top_area_bottom_px = base::ClampCeil(top_area_bottom_dip * scale); |
| const int top_area_height_px = top_area_bottom_px - client_bounds_px.y(); |
| |
| auto header = |
| PaintHeaderbar({client_bounds_px.width(), top_area_height_px}, |
| HeaderContext(solid_frame_, tiled_, focused), scale); |
| image = gfx::ImageSkia::CreateFrom1xBitmap(header); |
| // In GTK4, the headerbar gets clipped by the window. |
| if (GtkCheckVersion(4)) { |
| gfx::RectF bounds_px = |
| gfx::RectF(client_bounds_px.x(), client_bounds_px.y(), header.width(), |
| header.height()); |
| float radius_px = scale * top_corner_radius_dip_; |
| SkVector radii[4]{{radius_px, radius_px}, {radius_px, radius_px}, {}, {}}; |
| SkRRect clip; |
| clip.setRectRadii(gfx::RectFToSkRect(bounds_px), radii); |
| canvas->sk_canvas()->clipRRect(clip, SkClipOp::kIntersect, true); |
| } |
| draw_image(0, 0, header.width(), header.height(), client_bounds_px.x(), |
| client_bounds_px.y(), header.width(), header.height()); |
| } |
| |
| void WindowFrameProviderGtk::MaybeUpdateBitmaps(float scale) { |
| auto& asset = assets_[scale]; |
| if (asset.valid) { |
| return; |
| } |
| |
| asset.frame_size_px = std::ceil(kMaxFrameSizeDip * scale); |
| |
| gfx::Rect frame_bounds_dip(kMaxFrameSizeDip, kMaxFrameSizeDip, |
| 2 * kMaxFrameSizeDip, 2 * kMaxFrameSizeDip); |
| auto focused_context = DecorationContext(solid_frame_, tiled_, true); |
| frame_bounds_dip.Inset(-GtkStyleContextGetPadding(focused_context)); |
| frame_bounds_dip.Inset(-GtkStyleContextGetBorder(focused_context)); |
| gfx::Size bitmap_size(BitmapSizePx(asset), BitmapSizePx(asset)); |
| asset.focused_bitmap = PaintBitmap(bitmap_size, gfx::RectF(frame_bounds_dip), |
| focused_context, scale); |
| asset.unfocused_bitmap = |
| PaintBitmap(bitmap_size, gfx::RectF(frame_bounds_dip), |
| DecorationContext(solid_frame_, tiled_, false), scale); |
| |
| // In GTK4, there's no way to obtain the frame thickness from CSS values |
| // directly, so we must determine it experimentally based on the drawn |
| // bitmaps. |
| auto get_inset = [&](auto&& pixel_iterator) -> int { |
| for (int i = 0; i < asset.frame_size_px; ++i) { |
| if (SkColorGetA(pixel_iterator(i))) { |
| int inset_px = asset.frame_size_px - i; |
| return std::ceil(inset_px / scale); |
| } |
| } |
| return 0; |
| }; |
| |
| top_corner_radius_dip_ = ComputeTopCornerRadius(); |
| top_frame_is_translucent_ = !solid_frame_ && HeaderIsTranslucent(); |
| |
| const auto previous_frame_thickness_dip_ = frame_thickness_dip_; |
| frame_thickness_dip_ = gfx::Insets::TLBR( |
| get_inset([&](int i) { |
| return asset.focused_bitmap.getColor(2 * asset.frame_size_px, i); |
| }), |
| get_inset([&](int i) { |
| return asset.focused_bitmap.getColor(i, 2 * asset.frame_size_px); |
| }), |
| get_inset([&](int i) { |
| return asset.focused_bitmap.getColor(2 * asset.frame_size_px, |
| BitmapSizePx(asset) - i - 1); |
| }), |
| get_inset([&](int i) { |
| return asset.focused_bitmap.getColor(BitmapSizePx(asset) - i - 1, |
| 2 * asset.frame_size_px); |
| })); |
| if (!previous_frame_thickness_dip_.IsEmpty() && |
| frame_thickness_dip_ != previous_frame_thickness_dip_) { |
| // The possibility of the mismatch is quite low because this logic affects |
| // only mixed DPI setups on Linux, which itself is a rare configuration |
| // already, and there the user needs to use some unusual scale that would |
| // cause the mismatch. So in theory, this is possible, but in practice, it |
| // should never happen. |
| LOG(ERROR) << "Frame thickness mismatch! Old: [" |
| << previous_frame_thickness_dip_.ToString() << "], new: [" |
| << frame_thickness_dip_.ToString() << "]. Current scale is " |
| << scale << ". Please report to crbug.com/1240905."; |
| } |
| asset.frame_thickness_px = |
| gfx::ScaleToRoundedInsets(frame_thickness_dip_, scale); |
| |
| asset.valid = true; |
| } |
| |
| int WindowFrameProviderGtk::BitmapSizePx(const Asset& asset) const { |
| // The window decoration will be rendered into a square with this side length. |
| // The left and right sides of the decoration add 2 * kMaxDecorationThickness, |
| // and the window itself has size 2 * kMaxDecorationThickness. |
| return 4 * asset.frame_size_px; |
| } |
| |
| void WindowFrameProviderGtk::OnThemeChanged(GtkSettings* settings, |
| GtkParamSpec* param) { |
| assets_.clear(); |
| } |
| |
| } // namespace gtk |