// Copyright 2020 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include "DolphinQt/Config/ToolTipControls/BalloonTip.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #if defined(__APPLE__) #include #endif #include "DolphinQt/Settings.h" namespace { std::unique_ptr s_the_balloon_tip = nullptr; } // namespace void BalloonTip::ShowBalloon(const QString& title, const QString& message, const QPoint& target_arrow_tip_position, QWidget* const parent, const ShowArrow show_arrow, const int border_width) { HideBalloon(); if (message.isEmpty() && title.isEmpty()) return; #if defined(__APPLE__) QString the_message = message; the_message.replace(QStringLiteral(""), QStringLiteral("")); the_message.replace(QStringLiteral(""), QStringLiteral("")); QToolTip::showText(target_arrow_tip_position, the_message, parent); #else s_the_balloon_tip = std::make_unique(PrivateTag{}, title, message, parent); s_the_balloon_tip->UpdateBoundsAndRedraw(target_arrow_tip_position, show_arrow, border_width); #endif } void BalloonTip::HideBalloon() { #if defined(__APPLE__) QToolTip::hideText(); #else if (s_the_balloon_tip == nullptr) return; s_the_balloon_tip->hide(); s_the_balloon_tip.reset(); #endif } BalloonTip::BalloonTip(PrivateTag, const QString& title, QString message, QWidget* const parent) : QWidget(nullptr, Qt::ToolTip) { QColor window_color; QColor text_color; QColor dolphin_emphasis; Settings::Instance().GetToolTipStyle(window_color, text_color, dolphin_emphasis, m_border_color, parent->palette(), palette()); const auto style_sheet = QStringLiteral("background-color: #%1; color: #%2;") .arg(window_color.rgba(), 0, 16) .arg(text_color.rgba(), 0, 16); setStyleSheet(style_sheet); // Replace text in our our message if specific "tags" are used message.replace(QStringLiteral(""), QStringLiteral("").arg(dolphin_emphasis.rgba(), 0, 16)); message.replace(QStringLiteral(""), QStringLiteral("")); auto* const balloontip_layout = new QVBoxLayout; balloontip_layout->setSizeConstraint(QLayout::SetFixedSize); setLayout(balloontip_layout); const auto create_label = [=](const QString& text) { QLabel* const label = new QLabel; balloontip_layout->addWidget(label); label->setText(text); label->setTextFormat(Qt::RichText); const int max_width = label->screen()->availableGeometry().width() / 3; label->setMaximumWidth(max_width); if (label->sizeHint().width() > max_width) label->setWordWrap(true); return label; }; if (!title.isEmpty()) { QLabel* const title_label = create_label(title); QFont title_font = title_label->font(); title_font.setBold(true); title_label->setFont(title_font); } if (!message.isEmpty()) create_label(message); } void BalloonTip::paintEvent(QPaintEvent*) { QPainter painter(this); painter.drawPixmap(rect(), m_pixmap); } void BalloonTip::UpdateBoundsAndRedraw(const QPoint& target_arrow_tip_position, const ShowArrow show_arrow, const int border_full_width) { const float border_half_width = border_full_width / 2.0; // This should be odd so that the arrow tip is a single pixel wide. const int arrow_full_width = 35; const float arrow_half_width = arrow_full_width / 2.0; // The y distance between the inner edge of the rectangle border and the inner tip of the arrow // border, and also the distance between the outer edge of the rectangle border and the outer tip // of the arrow border const int arrow_height = (1 + arrow_full_width) / 2; // Distance between the label layout and the inner rectangle border edge const int balloon_interior_padding = 12; // Prevent the corners of the label layout from portruding into the rounded rectangle corners at // larger border sizes. const int rounded_corner_margin = border_half_width / 4; const int horizontal_margin = border_full_width + rounded_corner_margin + balloon_interior_padding; const int vertical_margin = horizontal_margin + arrow_height; // Create enough space around the layout containing the title and message to draw the balloon and // both arrow tips (at most one of which will be visible) layout()->setContentsMargins(horizontal_margin, vertical_margin, horizontal_margin, vertical_margin); QSize size_hint = sizeHint(); // These positions represent the middle of each edge of the BalloonTip's rounded rectangle const float rect_width = size_hint.width() - border_full_width; const float rect_height = size_hint.height() - border_full_width - 2 * arrow_height; const float rect_top = border_half_width + arrow_height; const float rect_bottom = rect_top + rect_height; const float rect_left = border_half_width; // rect_right isn't used for anything // Qt defines the radius of a rounded rectangle as "the radius of the ellipses defining the // corner". Unlike the rectangle's edges this corresponds to the outside of the rounded curve // instead of its middle, so we add the full width to the inner radius instead of the half width const float corner_base_inner_radius = 7.0; const float corner_outer_radius = corner_base_inner_radius + border_full_width; // This value is arbitrary but works well. const int base_arrow_x_offset = 34; // Adjust the offset inward to compensate for the border and rounded corner widths. This ensures // the arrow is on the flat part of the top/bottom border. const int adjusted_arrow_x_offset = base_arrow_x_offset + border_full_width + static_cast(border_half_width); // If the border is wide enough (or the BalloonTip small enough) the offset might end up past the // midpoint; if that happens just use the midpoint instead const int centered_arrow_x_offset = (size_hint.width() - arrow_full_width) / 2; // If the arrow is on the left this is the distance between the left edge of the BalloonTip and // the left edge of the arrow interior; otherwise the distance between the right edges. const int arrow_nearest_edge_x_offset = std::min(adjusted_arrow_x_offset, centered_arrow_x_offset); const int arrow_tip_x_offset = arrow_nearest_edge_x_offset + arrow_half_width; // The BalloonTip should be contained entirely within the screen that contains the target // position. QScreen* screen = QGuiApplication::screenAt(target_arrow_tip_position); if (screen == nullptr) { // If the target position isn't on any screen (which can happen if the window is partly off the // screen and the user hovers over the label) then use the screen containing the cursor instead. screen = QGuiApplication::screenAt(QCursor::pos()); } const QRect screen_rect = screen->geometry(); QPainterPath rect_path; rect_path.addRoundedRect(rect_left, rect_top, rect_width, rect_height, corner_outer_radius, corner_outer_radius); // The default pen cap style Qt::SquareCap extends drawn lines one half width before the starting // point and one half width after the ending point such that the starting and ending points are // surrounded by drawn pixels in both dimensions instead of just for the width. This is a // reasonable default but we need to draw lines precisely, and this behavior causes problems when // drawing lines of length and width 1 (which we do for the arrow interior, and also the arrow // border when border_full_width is 1). For those lines to correctly end up as a pixel we would // need to offset the start and end points by 0.5 inward. However, doing that would lead them to // be at the same point, and if the endpoints of a line are the same Qt simply doesn't draw it // regardless of the cap style. // // Using Qt::FlatCap instead fixes the issue. m_pixmap = QPixmap(size_hint); QPen border_pen(m_border_color, border_full_width); border_pen.setCapStyle(Qt::FlatCap); QPainter balloon_painter(&m_pixmap); balloon_painter.setPen(border_pen); balloon_painter.setBrush(palette().color(QPalette::Window)); balloon_painter.drawPath(rect_path); QBitmap mask_bitmap(size_hint); mask_bitmap.fill(Qt::color0); QPen mask_pen(Qt::color1, border_full_width); mask_pen.setCapStyle(Qt::FlatCap); QPainter mask_painter(&mask_bitmap); mask_painter.setPen(mask_pen); mask_painter.setBrush(QBrush(Qt::color1)); mask_painter.drawPath(rect_path); const bool arrow_at_bottom = target_arrow_tip_position.y() - size_hint.height() + arrow_height >= 0; const bool arrow_at_left = target_arrow_tip_position.x() + size_hint.width() - arrow_tip_x_offset < screen_rect.width(); const float arrow_base_y = arrow_at_bottom ? rect_bottom - border_half_width : rect_top + border_half_width; const float arrow_tip_vertical_offset = arrow_at_bottom ? arrow_height : -arrow_height; const float arrow_tip_interior_y = arrow_base_y + arrow_tip_vertical_offset; const float arrow_tip_exterior_y = arrow_tip_interior_y + (arrow_at_bottom ? border_full_width : -border_full_width); const float arrow_base_left_edge_x = arrow_at_left ? arrow_nearest_edge_x_offset : size_hint.width() - arrow_nearest_edge_x_offset - arrow_full_width; const float arrow_base_right_edge_x = arrow_base_left_edge_x + arrow_full_width; const float arrow_tip_x = arrow_base_left_edge_x + arrow_half_width; if (show_arrow == ShowArrow::Yes) { // Drawing diagonal lines in Qt is filled with edge cases and inexplicable behavior. Getting it // to do what you want at one border size is simple enough, but doing so flexibly is an exercise // in futility. Some examples: // * For some values of x, diagonal lines of width x and x+1 are drawn exactly the same. // * When drawing a triangle where p1 and p3 have exactly the same y value, they can be drawn at // different heights. // * Lines of width 1 sometimes get drawn one pixel past where they should even with FlatCap, // but only on the left side (regardless of which direction the stroke was). // // Instead of dealing with all that, fake it with vertical lines which are much better behaved. // Draw a bunch of vertical lines with width 1 to form the arrow border and interior. QPainterPath arrow_border_path; QPainterPath arrow_interior_fill_path; const float y_end_offset = arrow_at_bottom ? border_full_width : -border_full_width; // Draw the arrow border and interior lines from the outside inward. Each loop iteration draws // one pair of border lines and one pair of interior lines. for (int i = 1; i <= arrow_half_width; i++) { const float x_offset_from_arrow_base_edge = i - 0.5; const float border_y_start = arrow_base_y + (arrow_at_bottom ? i : -i); const float border_y_end = border_y_start + y_end_offset; const float interior_y_start = arrow_base_y; const float interior_y_end = border_y_start; const float left_line_x = arrow_base_left_edge_x + x_offset_from_arrow_base_edge; const float right_line_x = arrow_base_right_edge_x - x_offset_from_arrow_base_edge; arrow_border_path.moveTo(left_line_x, border_y_start); arrow_border_path.lineTo(left_line_x, border_y_end); arrow_border_path.moveTo(right_line_x, border_y_start); arrow_border_path.lineTo(right_line_x, border_y_end); arrow_interior_fill_path.moveTo(left_line_x, interior_y_start); arrow_interior_fill_path.lineTo(left_line_x, interior_y_end); arrow_interior_fill_path.moveTo(right_line_x, interior_y_start); arrow_interior_fill_path.lineTo(right_line_x, interior_y_end); } // The middle border line arrow_border_path.moveTo(arrow_tip_x, arrow_tip_interior_y); arrow_border_path.lineTo(arrow_tip_x, arrow_tip_interior_y + y_end_offset); // The middle interior line arrow_interior_fill_path.moveTo(arrow_tip_x, arrow_base_y); arrow_interior_fill_path.lineTo(arrow_tip_x, arrow_tip_interior_y); border_pen.setWidth(1); balloon_painter.setPen(border_pen); balloon_painter.drawPath(arrow_border_path); QPen arrow_interior_fill_pen(palette().color(QPalette::Window), 1); arrow_interior_fill_pen.setCapStyle(Qt::FlatCap); balloon_painter.setPen(arrow_interior_fill_pen); balloon_painter.drawPath(arrow_interior_fill_path); mask_pen.setWidth(1); mask_painter.setPen(mask_pen); mask_painter.drawPath(arrow_border_path); mask_painter.drawPath(arrow_interior_fill_path); } setMask(mask_bitmap); // Place the arrow tip at the target position whether the arrow tip is drawn or not const int target_balloontip_global_x = target_arrow_tip_position.x() - static_cast(arrow_tip_x); const int rightmost_valid_balloontip_global_x = screen_rect.left() + screen_rect.width() - size_hint.width(); // If the balloon would extend off the screen, push it left or right until it's not const int actual_balloontip_global_x = std::max(screen_rect.left(), std::min(rightmost_valid_balloontip_global_x, target_balloontip_global_x)); // The tip pixel should be in the middle of the control, and arrow_tip_exterior_y is at the bottom // of that pixel. When arrow_at_bottom is true the arrow is above arrow_tip_exterior_y and so the // tip pixel is in the right place, but when it's false the arrow is below arrow_tip_exterior_y // so the tip pixel would be the one below that. Make this adjustment to fix that. const int tip_pixel_adjustment = arrow_at_bottom ? 0 : 1; const int actual_balloontip_global_y = target_arrow_tip_position.y() - arrow_tip_exterior_y - tip_pixel_adjustment; move(actual_balloontip_global_x, actual_balloontip_global_y); show(); }