diff --git a/include/eepp/ui/uiplacementutils.hpp b/include/eepp/ui/uiplacementutils.hpp new file mode 100644 index 000000000..b9e48635e --- /dev/null +++ b/include/eepp/ui/uiplacementutils.hpp @@ -0,0 +1,48 @@ +#include +#include +#include + +using namespace EE::Math; + +namespace EE::UI { + +enum class PlacementDirection { Right, Left, Bottom, Top, None }; + +enum class PlacementLayout { + Horizontal, // Strongly favors Right/Left (Ideal for tooltips/documentation) + Vertical // Strongly favors Bottom/Top (Ideal for dropdowns/menus) +}; + +struct PopupPlacementConfig { + Rectf areaRect; // The full visible screen/window area bounds + Rectf targetRect; // The main box we are attaching the popup to + Rectf alignRect; // Box to horizontally align with + Rectf avoidRect; // Box to strictly avoid overlapping (e.g., cursor line) + + PlacementLayout layoutBias = PlacementLayout::Horizontal; + bool supportHorizontal = true; // Set to false for Dropdowns + bool supportVertical = true; // Set to false if you strictly want side-panels + + Float userMaxWidth; + Float margin = 4.f; + + // Thresholds + Float minHorizontalSpace = 200.f; // Min width needed to trigger Horizontal bonus + Float minVerticalSpace = 100.f; // Min height needed to trigger Vertical bonus + Float minScoreHeight; // Minimum height considered "good" + Float maxScoreHeight; // Cap for height in the score calculation +}; + +struct PopupPlacementResult { + Rectf rect; + PlacementDirection direction = PlacementDirection::None; +}; + +class UIPlacementUtils { + public: + static PopupPlacementResult + findBestPopupPlacement( const PopupPlacementConfig& config, + const std::function& measureContentCb ); +}; + +} // namespace EE::UI diff --git a/src/eepp/ui/uiplacementutils.cpp b/src/eepp/ui/uiplacementutils.cpp new file mode 100644 index 000000000..56589f8b0 --- /dev/null +++ b/src/eepp/ui/uiplacementutils.cpp @@ -0,0 +1,150 @@ +#include + +namespace EE::UI { + +PopupPlacementResult UIPlacementUtils::findBestPopupPlacement( + const PopupPlacementConfig& config, + const std::function& measureContentCb ) { + bool hasAvoidRect = + ( config.avoidRect.getSize().getWidth() > 0 && config.avoidRect.getSize().getHeight() > 0 ); + Float bottomAvoid = hasAvoidRect ? std::max( config.targetRect.Bottom, config.avoidRect.Bottom ) + : config.targetRect.Bottom; + Float topAvoid = hasAvoidRect ? std::min( config.targetRect.Top, config.avoidRect.Top ) + : config.targetRect.Top; + + struct Candidate { + PlacementDirection direction; + Float availableWidth; + Float availableHeight; + Float score; + Float attachX; + Float attachY; + }; + + SmallVector candidates; + + // Populate Candidates based on supported axes + if ( config.supportHorizontal ) { + candidates.push_back( + { PlacementDirection::Right, + std::max( 0.f, config.areaRect.Right - config.targetRect.Right - config.margin ), + std::max( 0.f, config.areaRect.getHeight() - config.margin * 2 ), 0, + config.targetRect.Right + config.margin, config.alignRect.Top } ); + + candidates.push_back( + { PlacementDirection::Left, + std::max( 0.f, config.targetRect.Left - config.areaRect.Left - config.margin ), + std::max( 0.f, config.areaRect.getHeight() - config.margin * 2 ), 0, + config.targetRect.Left - config.margin, config.alignRect.Top } ); + } + + if ( config.supportVertical ) { + candidates.push_back( + { PlacementDirection::Bottom, + std::max( 0.f, config.areaRect.getWidth() - config.margin * 2 ), + std::max( 0.f, config.areaRect.Bottom - bottomAvoid - config.margin ), 0, + config.targetRect.Left, bottomAvoid + config.margin } ); + + candidates.push_back( { PlacementDirection::Top, + std::max( 0.f, config.areaRect.getWidth() - config.margin * 2 ), + std::max( 0.f, topAvoid - config.areaRect.Top - config.margin ), 0, + config.targetRect.Left, topAvoid - config.margin } ); + } + + if ( candidates.empty() ) { + return { Rectf(), PlacementDirection::None }; + } + + // Score the candidates + for ( auto& c : candidates ) { + Float maxW = std::min( config.userMaxWidth, c.availableWidth ); + + // Base area score + c.score = maxW * std::min( c.availableHeight, config.maxScoreHeight ); + + // Apply a massive penalty (instead of a hard zero) if space is terrible. + // This ensures we always pick the "least bad" option on tiny screens. + if ( maxW < 50 || c.availableHeight < config.minScoreHeight ) { + c.score *= 0.001f; + } + + // Apply layout biases and tie-breakers + if ( config.layoutBias == PlacementLayout::Horizontal ) { + if ( c.direction == PlacementDirection::Right || + c.direction == PlacementDirection::Left ) { + if ( c.availableWidth >= config.minHorizontalSpace ) { + c.score += 1000000; + if ( c.direction == PlacementDirection::Right ) + c.score += 100; // Tie-breaker + } + } + } else if ( config.layoutBias == PlacementLayout::Vertical ) { + if ( c.direction == PlacementDirection::Bottom || + c.direction == PlacementDirection::Top ) { + if ( c.availableHeight >= config.minVerticalSpace ) { + c.score += 1000000; + if ( c.direction == PlacementDirection::Bottom ) + c.score += 100; // Tie-breaker + } + } + } + } + + std::sort( candidates.begin(), candidates.end(), + []( const auto& a, const auto& b ) { return a.score > b.score; } ); + + const auto& best = candidates.front(); + + // Measurement step + Float allocatedWidth = std::max( 0.f, std::min( config.userMaxWidth, best.availableWidth ) ); + Sizef boxSize = measureContentCb( allocatedWidth ); + + // Height constraint + boxSize.setHeight( std::min( boxSize.getHeight(), best.availableHeight ) ); + + Vector2f pos; + if ( best.direction == PlacementDirection::Right ) { + pos.x = best.attachX; + pos.y = best.attachY; + } else if ( best.direction == PlacementDirection::Left ) { + pos.x = best.attachX - boxSize.getWidth(); + pos.y = best.attachY; + } else if ( best.direction == PlacementDirection::Bottom ) { + pos.x = best.attachX; + pos.y = best.attachY; + } else { // Top + pos.x = best.attachX; + pos.y = best.attachY - boxSize.getHeight(); + } + + // Final Clamping + if ( best.direction == PlacementDirection::Right ) { + pos.x = std::min( pos.x, config.areaRect.Right - boxSize.getWidth() ); + pos.x = std::max( pos.x, config.targetRect.Right + config.margin ); + pos.y = std::max( + config.areaRect.Top + config.margin, + std::min( pos.y, config.areaRect.Bottom - boxSize.getHeight() - config.margin ) ); + } else if ( best.direction == PlacementDirection::Left ) { + pos.x = std::max( pos.x, config.areaRect.Left + config.margin ); + pos.x = std::min( pos.x, config.targetRect.Left - boxSize.getWidth() - config.margin ); + pos.y = std::max( + config.areaRect.Top + config.margin, + std::min( pos.y, config.areaRect.Bottom - boxSize.getHeight() - config.margin ) ); + } else if ( best.direction == PlacementDirection::Bottom ) { + pos.y = std::min( pos.y, config.areaRect.Bottom - boxSize.getHeight() - config.margin ); + pos.y = std::max( pos.y, bottomAvoid + config.margin ); + pos.x = std::max( + config.areaRect.Left + config.margin, + std::min( pos.x, config.areaRect.Right - boxSize.getWidth() - config.margin ) ); + } else { // Top + pos.y = std::max( pos.y, config.areaRect.Top + config.margin ); + pos.y = std::min( pos.y, topAvoid - boxSize.getHeight() - config.margin ); + pos.x = std::max( + config.areaRect.Left + config.margin, + std::min( pos.x, config.areaRect.Right - boxSize.getWidth() - config.margin ) ); + } + + return { Rectf( pos, boxSize ).round(), best.direction }; +} + +} // namespace EE::UI diff --git a/src/tools/ecode/plugins/autocomplete/autocompleteplugin.cpp b/src/tools/ecode/plugins/autocomplete/autocompleteplugin.cpp index 1f4e233af..84d4f7487 100644 --- a/src/tools/ecode/plugins/autocomplete/autocompleteplugin.cpp +++ b/src/tools/ecode/plugins/autocomplete/autocompleteplugin.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -1275,130 +1276,41 @@ Rectf AutoCompletePlugin::findBestDocumentationPlacement( UICodeEditor* editor, const Rectf& anchorBox, const Rectf& rowRect, bool drawUp, Float lineHeight ) { - const Rectf& areaRect = editor->getScreenRect(); - Float userMaxWidth = editor->convertLength( + PopupPlacementConfig config; + config.areaRect = editor->getScreenRect(); + config.targetRect = anchorBox; + config.alignRect = rowRect; + // The avoidRect is the user's cursor line. This ensures Top/Bottom placement skips the line + // being typed. + Float cursorLineTop = + rowRect.Top - lineHeight; // Approximating cursor location based on the suggestion row + config.avoidRect = + Rectf( anchorBox.Left, cursorLineTop, editor->getPixelsSize().getWidth(), lineHeight ); + config.userMaxWidth = editor->convertLength( StyleSheetLength( mMaxSuggestionDocumentationWidth ), editor->getPixelsSize().getWidth() ); - const Float minSideWidth = PixelDensity::dpToPx( 200.f ); - const Float margin = PixelDensity::dpToPx( 2.f ); // Visual breathing room - - struct PlacementCandidate { - enum Type { Right, Left, Bottom, Top } type; - Float availableWidth; - Float availableHeight; - Float score; + config.minHorizontalSpace = PixelDensity::dpToPx( 200.f ); + config.margin = PixelDensity::dpToPx( 2.f ); + config.minScoreHeight = mRowHeight * 2; + config.maxScoreHeight = mRowHeight * 10; + auto measureContent = [&]( Float availableMaxWidth ) -> Sizef { + Float textWrapWidth = + std::max( 0.f, availableMaxWidth - mBoxPadding.Left - mBoxPadding.Right ); + mSuggestionDoc.setMaxWrapWidth( textWrapWidth ); + bool changed = mSuggestionDoc.setString( suggestion.documentation.value ); + if ( changed ) { + bool forceHTML = String::startsWith( suggestion.detail, "Emmet" ); + if ( suggestion.documentation.kind == LSPMarkupKind::MarkDown || forceHTML ) { + const auto& syntaxDef = + forceHTML ? SyntaxDefinitionManager::instance()->getByLSPName( "html" ) + : SyntaxDefinitionManager::instance()->getByLSPName( "markdown" ); + SyntaxTokenizer::tokenizeText( syntaxDef, editor->getColorScheme(), &mSuggestionDoc, + 0, 0xFFFFFFFF, true, "\n\t " ); + } + } + return { mSuggestionDoc.getTextWidth() + mBoxPadding.Left + mBoxPadding.Right, + mSuggestionDoc.getTextHeight() + mBoxPadding.Top + mBoxPadding.Bottom }; }; - - // Calculate available space, strictly removing the cursor's line so we don't cover what the - // user is typing. - Float topAvail = std::max( 0.f, ( drawUp ? ( anchorBox.Top - areaRect.Top - lineHeight ) - : ( anchorBox.Top - areaRect.Top ) ) - - margin ); - Float bottomAvail = - std::max( 0.f, ( drawUp ? ( areaRect.Bottom - anchorBox.Bottom ) - : ( areaRect.Bottom - anchorBox.Bottom - lineHeight ) ) - - margin ); - Float rightAvail = std::max( 0.f, areaRect.Right - anchorBox.Right - margin ); - Float leftAvail = std::max( 0.f, anchorBox.Left - areaRect.Left - margin ); - - SmallVector candidates = { - { PlacementCandidate::Right, rightAvail, std::max( 0.f, areaRect.getHeight() - margin * 2 ), - 0 }, - { PlacementCandidate::Left, leftAvail, std::max( 0.f, areaRect.getHeight() - margin * 2 ), - 0 }, - { PlacementCandidate::Bottom, std::max( 0.f, areaRect.getWidth() - margin * 2 ), - bottomAvail, 0 }, - { PlacementCandidate::Top, std::max( 0.f, areaRect.getWidth() - margin * 2 ), topAvail, - 0 } }; - - for ( auto& c : candidates ) { - Float maxW = - std::min( userMaxWidth, c.availableWidth - mBoxPadding.Left - mBoxPadding.Right ); - if ( maxW < 50 || c.availableHeight < mRowHeight * 2 ) { - c.score = 0; - continue; - } - c.score = maxW * std::min( c.availableHeight, mRowHeight * 10 ); - if ( ( c.type == PlacementCandidate::Right || c.type == PlacementCandidate::Left ) && - c.availableWidth >= minSideWidth ) { - c.score += 1000000; // Prefer side placement if it has enough width - } - } - - std::sort( candidates.begin(), candidates.end(), - []( const auto& a, const auto& b ) { return a.score > b.score; } ); - - const auto& best = candidates.front(); - - // Edge case: No space anywhere on screen (zero score) - if ( best.score == 0 ) { - return Rectf(); // Return empty rect, `postDraw` will skip drawing - } - - Float maxWidth = std::max( - 0.f, std::min( userMaxWidth, best.availableWidth - mBoxPadding.Left - mBoxPadding.Right ) ); - - mSuggestionDoc.setMaxWrapWidth( maxWidth ); - bool changed = mSuggestionDoc.setString( suggestion.documentation.value ); - - if ( changed ) { - bool forceHTML = String::startsWith( suggestion.detail, "Emmet" ); - if ( suggestion.documentation.kind == LSPMarkupKind::MarkDown || forceHTML ) { - const auto& syntaxDef = - forceHTML ? SyntaxDefinitionManager::instance()->getByLSPName( "html" ) - : SyntaxDefinitionManager::instance()->getByLSPName( "markdown" ); - SyntaxTokenizer::tokenizeText( syntaxDef, editor->getColorScheme(), &mSuggestionDoc, 0, - 0xFFFFFFFF, true, "\n\t " ); - } - } - - Sizef boxSize = { mSuggestionDoc.getTextWidth() + mBoxPadding.Left + mBoxPadding.Right, - mSuggestionDoc.getTextHeight() + mBoxPadding.Top + mBoxPadding.Bottom }; - - // Height Clamping: Prevent background box from bleeding off-screen. - // Text that overflows this will simply get clipped or run over gracefully. - boxSize.setHeight( std::min( boxSize.getHeight(), best.availableHeight ) ); - - Vector2f pos; - if ( best.type == PlacementCandidate::Right ) { - pos.x = anchorBox.Right + margin; - pos.y = rowRect.Top; - } else if ( best.type == PlacementCandidate::Left ) { - pos.x = anchorBox.Left - boxSize.getWidth() - margin; - pos.y = rowRect.Top; - } else if ( best.type == PlacementCandidate::Bottom ) { - pos.x = anchorBox.Left; - pos.y = anchorBox.Bottom + margin + ( !drawUp ? lineHeight : 0 ); - } else { // Top - pos.x = anchorBox.Left; - pos.y = anchorBox.Top - boxSize.getHeight() - margin - ( drawUp ? lineHeight : 0 ); - } - - // Final Clamping: Kept firmly inside `areaRect` but enforcing boundaries to prevent overlap - if ( best.type == PlacementCandidate::Right ) { - pos.x = std::min( pos.x, areaRect.Right - boxSize.getWidth() ); - pos.x = std::max( pos.x, anchorBox.Right + margin ); - pos.y = std::max( areaRect.Top + margin, - std::min( pos.y, areaRect.Bottom - boxSize.getHeight() - margin ) ); - } else if ( best.type == PlacementCandidate::Left ) { - pos.x = std::max( pos.x, areaRect.Left + margin ); - pos.x = std::min( pos.x, anchorBox.Left - boxSize.getWidth() - margin ); - pos.y = std::max( areaRect.Top + margin, - std::min( pos.y, areaRect.Bottom - boxSize.getHeight() - margin ) ); - } else if ( best.type == PlacementCandidate::Bottom ) { - pos.y = std::min( pos.y, areaRect.Bottom - boxSize.getHeight() - margin ); - Float minBottom = anchorBox.Bottom + margin + ( !drawUp ? lineHeight : 0 ); - pos.y = std::max( pos.y, minBottom ); - pos.x = std::max( areaRect.Left + margin, - std::min( pos.x, areaRect.Right - boxSize.getWidth() - margin ) ); - } else { // Top - pos.y = std::max( pos.y, areaRect.Top + margin ); - Float maxTop = anchorBox.Top - boxSize.getHeight() - margin - ( drawUp ? lineHeight : 0 ); - pos.y = std::min( pos.y, maxTop ); - pos.x = std::max( areaRect.Left + margin, - std::min( pos.x, areaRect.Right - boxSize.getWidth() - margin ) ); - } - - return Rectf( pos, boxSize ).round(); + return UIPlacementUtils::findBestPopupPlacement( config, measureContent ).rect.round(); } bool AutoCompletePlugin::onMouseDown( UICodeEditor* editor, const Vector2i& position,