From 37378e3c1bdb8d085439225f8bb3b9990726150a Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Sun, 9 Aug 2026 22:09:41 +0200 Subject: [PATCH] allow to move the selection panel via mouse drag --- docs/requirements.md | 17 +++-- src/lib/core/FloatingPanelPlacement.cpp | 74 +++++++++++++++------ src/lib/core/FloatingPanelPlacement.h | 11 ++++ src/test/FloatingPanelPlacementTest.cpp | 75 +++++++++++++++++++++ src/ui/SelectionPanel.cpp | 88 +++++++++++++++++++++++-- src/ui/SelectionPanel.h | 25 ++++++- src/ui/selection/SelectionContent.cpp | 16 +++-- src/ui/selection/SelectionContent.h | 6 ++ 8 files changed, 274 insertions(+), 38 deletions(-) diff --git a/docs/requirements.md b/docs/requirements.md index 1922f94..1122664 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -428,7 +428,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro ### Layout -The screen is a single column: a header bar across the top and the game world view filling the whole area below it. There is no side panel. All three permanent UI widgets float over the game world — the build button bar (REQ-UI-BUILD-BAR) at its bottom center, the selection panel (REQ-UI-SELECTION-PANEL) beside whatever is currently selected, shown only while something is selected and holding its place on the screen until the next selection, and the controls panel (REQ-UI-CONTROLS-PANEL) in its bottom-left corner, beside the build button bar and rising above it only when the two would overlap. Blueprints have no permanent screen real estate; they are reached through modal dialogs (REQ-UI-BLUEPRINT-DIALOG): +The screen is a single column: a header bar across the top and the game world view filling the whole area below it. There is no side panel. All three permanent UI widgets float over the game world — the build button bar (REQ-UI-BUILD-BAR) at its bottom center, the selection panel (REQ-UI-SELECTION-PANEL) beside whatever is currently selected — or wherever the player has dragged it by its header (REQ-UI-SELECTION-PANEL-DRAG) — shown only while something is selected and holding its place on the screen until the next selection, and the controls panel (REQ-UI-CONTROLS-PANEL) in its bottom-left corner, beside the build button bar and rising above it only when the two would overlap. Blueprints have no permanent screen real estate; they are reached through modal dialogs (REQ-UI-BLUEPRINT-DIALOG): ``` +-----------------------------------------------------------+ @@ -460,11 +460,18 @@ The screen is a single column: a header bar across the top and the game world vi - **Anchor rectangle.** The panel is placed against the screen rectangle of the selection **at the moment that selection started**: the footprint of the single object selected (a building or construction site, an actor, or a piece of debris), or, when the selection started as a multi-selection (REQ-UI-MULTI-SELECT), the bounding box of all the objects it started with. - **Side.** The panel goes to the **right** of the anchor rectangle, separated from it by the panel's margin, whenever it fits within the view there. Otherwise it goes to the **left** of the anchor rectangle by that same margin. When it fits on neither side — a bounding box spanning most of the view, or an object too close to an edge — it is placed on whichever side leaves more room and then pushed inside the view. That is the one case in which the panel covers part of the selection. - **Vertical placement.** The panel's **top edge is aligned with the anchor rectangle's top edge** and it extends downward. Its bottom is limited by the lowest of: the view's bottom edge less the panel's margin; and the top edge, less that margin, of the build button bar (REQ-UI-BUILD-BAR) or the controls panel (REQ-UI-CONTROLS-PANEL) — but each of those two only where the panel's own horizontal extent actually overlaps that widget's current rectangle, so a panel whose column misses them is not shortened by them. Should the panel not fit above that limit, it is shifted up, as far as the view's top margin and no further; if it still does not fit, its height is capped at the space available there and the content scrolls vertically within it. - - **Fixed for the life of the selection.** The anchor rectangle and the side are determined once, when the selection starts, and are not revisited while that selection lasts; the panel's own size is the only thing that may still move it (see **Resizing in place** below). The panel **keeps its place on the screen** when the player scrolls the view (REQ-UI-SCROLL) and when a selected object moves under it (a selected ship flying away), rather than following the object — which may leave it beside nothing, or beside an object that has left the view entirely. It likewise does not move when the selection is **expanded** by adding objects or reduced by removing them (REQ-UI-MULTI-SELECT), nor when a selected object is destroyed or deconstructed. Starting a **new** selection — clicking a different object, or a box drag that replaces the selection — places the panel anew against the new anchor rectangle. - - **Resizing in place.** Only the anchor rectangle and the chosen side are fixed for the life of the selection; the panel's geometry is **re-solved from them** whenever its content size changes (a section appearing or disappearing as the selection's state changes), the view is resized, or the build button bar's or controls panel's rectangle changes. Re-solving keeps the two edges the panel was placed by — its top edge, and the edge facing the anchor rectangle (its left edge when it sits to the right of the selection, its right edge when it sits to the left) — so the panel grows away from the selection rather than over it, and it never switches sides for as long as the selection lasts. What re-solving may change is the vertical result: growth that would take the panel outside the view or into either of those two widgets is resolved as in **Vertical placement** above, by shifting it up and capping its height, and a panel that shrinks again regains the room. + - **Fixed for the life of the selection.** The anchor rectangle and the side are determined once, when the selection starts, and are not revisited while that selection lasts; the panel's own size is the only thing that may still move it (see **Resizing in place** below). The player may override the resulting position by dragging the panel's header (REQ-UI-SELECTION-PANEL-DRAG); the dragged position then takes the anchor rectangle's and the side's place for the rest of that selection. The panel **keeps its place on the screen** when the player scrolls the view (REQ-UI-SCROLL) and when a selected object moves under it (a selected ship flying away), rather than following the object — which may leave it beside nothing, or beside an object that has left the view entirely. It likewise does not move when the selection is **expanded** by adding objects or reduced by removing them (REQ-UI-MULTI-SELECT), nor when a selected object is destroyed or deconstructed. Starting a **new** selection — clicking a different object, or a box drag that replaces the selection — places the panel anew against the new anchor rectangle. + - **Resizing in place.** Only the anchor rectangle and the chosen side are fixed for the life of the selection (or, once the panel has been dragged, the dragged desired position — REQ-UI-SELECTION-PANEL-DRAG); the panel's geometry is **re-solved from them** whenever its content size changes (a section appearing or disappearing as the selection's state changes), the view is resized, or the build button bar's or controls panel's rectangle changes. Re-solving keeps the two edges the panel was placed by — its top edge, and the edge facing the anchor rectangle (its left edge when it sits to the right of the selection, its right edge when it sits to the left) — so the panel grows away from the selection rather than over it, and it never switches sides for as long as the selection lasts. What re-solving may change is the vertical result: growth that would take the panel outside the view or into either of those two widgets is resolved as in **Vertical placement** above, by shifting it up and capping its height, and a panel that shrinks again regains the room. - **Visibility.** The panel is shown only while at least one object is selected. With an empty selection it is not shown at all (REQ-UI-EMPTY-SELECTION), leaving the full game world view visible. - **Overlay behavior.** As for the build button bar (REQ-UI-BUILD-BAR): the panel occludes the strip of the game world it covers; the world view itself keeps its full extent and the view's scrolling, ghost rendering, and tile geometry are unaffected. It is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the panel. The panel never overlaps the build button bar or the controls panel, because it stays above both wherever their rectangles meet its own; neither of them ever moves on the panel's account (REQ-UI-BUILD-BAR, REQ-UI-CONTROLS-PANEL). - - **Input.** Mouse events over the panel are consumed by the panel and never reach the game world: hovering it shows no builder-mode ghost at the tile beneath, and clicking it neither places a building nor changes the selection. Right-clicking the panel does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG). + - **Input.** Mouse events over the panel are consumed by the panel and never reach the game world: hovering it shows no builder-mode ghost at the tile beneath, and clicking it neither places a building nor changes the selection. Right-clicking the panel does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG). Beside the controls its content offers, the panel's own chrome offers one gesture: the header drag that moves it (REQ-UI-SELECTION-PANEL-DRAG). +- REQ-UI-SELECTION-PANEL-DRAG: **Moving the panel by its header.** The player can move the selection panel by pressing the left mouse button on the panel's **header** (REQ-UI-SELECTION-CARD) and dragging: the panel follows the cursor for the duration of the drag and stays where it is dropped on release. The header is the whole drag handle, and no other part of the panel starts a drag. + - **Desired position, not resolved position.** A drag sets only the panel's **desired top-left corner** in view coordinates. Where the panel actually lands is resolved from that desired position by the rules of REQ-UI-SELECTION-PANEL, exactly as an anchor-derived position is: the panel keeps its margin from the view's edges; its bottom is limited by the top edge, less that margin, of the build button bar (REQ-UI-BUILD-BAR) and of the controls panel (REQ-UI-CONTROLS-PANEL), but each only where the panel's own horizontal extent actually overlaps that widget's current rectangle; and a panel that does not fit above that limit is shifted up as far as the view's top margin and, failing that, capped in height with its content scrolling. The player therefore cannot park the panel over either widget, and neither widget ever moves on the panel's account (REQ-UI-BUILD-BAR, REQ-UI-CONTROLS-PANEL) — stepping around them stays entirely the panel's job. + - **The desired position survives the resolution.** Resolving does not overwrite what the player set: the desired position is retained as dropped, so a panel that had to be shifted up or shortened returns to it as soon as the obstruction stops overlapping it — its content shrinks, the bar's button set changes (REQ-LOCK-BUILDING), the controls panel's context changes, or the view is resized. A desired position that the current view cannot honour at all is likewise kept, so enlarging the window brings the panel back to it. + - **What the drag replaces.** From the first drag on, the desired position replaces the anchor rectangle and the side (REQ-UI-SELECTION-PANEL) for the rest of the current selection; the panel no longer has a side and never switches to one. Re-solving (the **Resizing in place** rule of REQ-UI-SELECTION-PANEL) then keeps the top and left edges of the desired position, in place of the top edge and the edge facing the anchor, so the panel still grows away from where the player put it rather than over it. The panel may be dragged repeatedly; each drag replaces the previous desired position. + - **Scope: the current selection.** The desired position lasts as long as the selection it was set in — across the panel's own resizing, view resizes, and view scrolling (REQ-UI-SELECTION-PANEL), and across the selection being expanded or reduced (REQ-UI-MULTI-SELECT). Starting a **new** selection discards it: the panel is placed anew against the new anchor rectangle (REQ-UI-SELECTION-PANEL), and the player drags it again if they want it elsewhere. + - **Input.** The drag consumes its mouse events like every other event over the panel (REQ-UI-SELECTION-PANEL): the press, the movement, and the release never reach the game world, so dragging the header neither box-selects (REQ-UI-MULTI-SELECT) nor places belts (REQ-BLD-BELT-DRAG). The drag continues while the cursor moves outside the panel or outside the view, and ends when the left button is released, wherever that happens. A press and release on the header without movement moves nothing and has no other effect. + - **Presentation only.** Moving the panel is not a player command: it never enters the replay stream and has no effect on the simulation, consistent with the controls panel's collapsed state (REQ-UI-CONTROLS-PANEL). The desired position is not saved to disk. - REQ-UI-MODAL-DIM: While a modal dialog, menu, or full-screen state screen is open on top of the game, a transparent black overlay (a dim/scrim) is drawn over the **entire game window** — the header bar, the game world view, and the widgets floating over it (the build button bar, REQ-UI-BUILD-BAR, the selection panel, REQ-UI-SELECTION-PANEL, and the controls panel, REQ-UI-CONTROLS-PANEL) — behind that modal, so the game reads as inactive while the modal holds focus. The overlay is shown for every modal that auto-pauses the simulation — the escape menu (REQ-UI-GAME-MENU), the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP), the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE), and the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The same applies when one modal hands directly off to another — the blueprint save dialog opening the blueprint selection dialog on confirm (REQ-UI-BLUEPRINT-CREATE): the dim persists across the handoff rather than flickering off and back on, and the simulation is not resumed in between. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation. ### Game World @@ -538,7 +545,7 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE - REQ-UI-EMPTY-SELECTION: When nothing is selected (no building, construction site, ship, defence station, or piece of debris), the selection panel is not shown at all — it is hidden rather than shown empty, so the full game world view is visible (REQ-UI-SELECTION-PANEL). It reappears as soon as an object is selected. - REQ-UI-SELECTION-CATEGORIES: **Selection categories and precedence.** Every selectable object belongs to one of two mutually exclusive selection categories: **buildings** (buildings and construction sites) and **field objects** (ships and defence stations — player or enemy — together with debris). A single selection holds objects from only one category at a time. Field objects of different kinds may be selected together (e.g. several ships plus debris, freely mixing player and enemy actors). Buildings are exclusive and take precedence — **buildings win**: selecting a building (by click, Ctrl+click, or a box-drag covering at least one building) clears any field selection and yields a buildings-only selection, and conversely selecting any field object clears any building selection. Point hit-testing prefers a building over a coincident field object, and among field objects prefers an actor (ship or defence station) over a coincident piece of debris (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT). - REQ-UI-SELECTION-CARD: **Card structure.** Every panel content is a card with the same three parts, top to bottom: - - **Header** — always shown. It holds the selection's identity symbol on the left — the building's icon glyph (REQ-UI-WORLD-ICON), a ship's schematic color swatch, or the kind symbol of a defence station or piece of debris — the selection's name beside it, and one optional **right slot**. The right slot holds a status indicator (REQ-UI-SELECTION-STATUS), a ship's current behavior (REQ-UI-SHIP-BEHAVIOR), or an object count — never more than one of them; which one applies is stated per content in REQ-UI-SELECTION-CONTENT. + - **Header** — always shown. It holds the selection's identity symbol on the left — the building's icon glyph (REQ-UI-WORLD-ICON), a ship's schematic color swatch, or the kind symbol of a defence station or piece of debris — the selection's name beside it, and one optional **right slot**. The right slot holds a status indicator (REQ-UI-SELECTION-STATUS), a ship's current behavior (REQ-UI-SHIP-BEHAVIOR), or an object count — never more than one of them; which one applies is stated per content in REQ-UI-SELECTION-CONTENT. The header carries no control of its own, and doubles as the panel's drag handle (REQ-UI-SELECTION-PANEL-DRAG). - **Configuration group** — the controls that change how the selected object is set up: the recipe/schematic selection control (REQ-UI-SELECT-BUTTON), a shipyard's layout preview and Configure button (REQ-MOD-UI-PREVIEW), and a splitter's output filters (REQ-BLD-SPLITTER). It is shown identically for an operational building and for a construction site of the same type (REQ-BLD-SITE-CONFIG). - **Runtime group** — what the object is currently doing: buffer contents, production progress, HP, remaining scrap, and the belt clear action (REQ-UI-BELT-CLEAR). Where the object has **HP**, its bar is the first thing in this group, above everything else the card shows (REQ-UI-HQ-PANEL, REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL) — how close the thing is to dying outranks what it is holding. For a **construction site** the entire runtime group is replaced by a captioned `Construction` section: a progress bar filled to the site's construction completion with that completion as an integer percentage beside the caption — the same value the world draws on the footprint (REQ-UI-CONSTRUCTION-PROGRESS) — followed by a note that buffers appear once the building is built, because a site has neither buffers nor a production cycle (REQ-BLD-SITE-CONFIG). That section sits **directly below the header, above the configuration group**, so how far along the site is reads first; the configuration group is otherwise unaffected and stays visible on a site. diff --git a/src/lib/core/FloatingPanelPlacement.cpp b/src/lib/core/FloatingPanelPlacement.cpp index 19ebe59..cf1218b 100644 --- a/src/lib/core/FloatingPanelPlacement.cpp +++ b/src/lib/core/FloatingPanelPlacement.cpp @@ -2,6 +2,39 @@ #include +namespace +{ + +// The part every placement shares, whatever put the panel where it wants to be: it is +// pushed inside band rather than hanging off it, shortened to what its own column has +// free, and lifted by however much of it hangs below that. wantedLeftPx and wantedTopPx +// are where the panel would stand if nothing were in the way. +QRect fitInBand(const QRect& band, int wantedLeftPx, int wantedTopPx, QSize wantedSize, + const std::vector& occupiedRects, int marginPx) +{ + const int widthPx = std::min(wantedSize.width(), band.width()); + + int leftPx = std::min(wantedLeftPx, band.right() - widthPx + 1); + leftPx = std::max(leftPx, band.left()); + + // Only the widgets its own column meets can shorten it. + const int bottomPx = getAvailableBottomPx(band, occupiedRects, leftPx, + leftPx + widthPx - 1, marginPx); + const int heightPx = + std::min(wantedSize.height(), std::max(0, bottomPx - band.top() + 1)); + + // Never above the band: a panel taller than the space left is capped instead, and + // scrolls. + int topPx = std::max(wantedTopPx, band.top()); + topPx = std::min(topPx, bottomPx - heightPx + 1); + topPx = std::max(topPx, band.top()); + + return QRect(leftPx, topPx, widthPx, heightPx); +} + +} // namespace + + int getAvailableBottomPx(const QRect& band, const std::vector& occupiedRects, int leftPx, int rightPx, int marginPx) { @@ -51,26 +84,27 @@ QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide si const int widthPx = std::min(wantedSize.width(), band.width()); // Against the anchor on the chosen side, growing away from it: the edge facing the - // selection is the one that stays put as the panel's content resizes. - int leftPx = (side == PanelSide::Right) ? anchorRect.right() + marginPx + 1 - : anchorRect.left() - marginPx - widthPx; - // A panel that does not fit there is pushed back inside the view rather than hanging - // off it, which is what puts it over the selection when neither side had room. - leftPx = std::min(leftPx, band.right() - widthPx + 1); - leftPx = std::max(leftPx, band.left()); - - // Only the widgets its own column meets can shorten it. - const int bottomPx = getAvailableBottomPx(band, occupiedRects, leftPx, - leftPx + widthPx - 1, marginPx); - const int heightPx = - std::min(wantedSize.height(), std::max(0, bottomPx - band.top() + 1)); + // selection is the one that stays put as the panel's content resizes. A panel that + // does not fit there is pushed back inside the view rather than hanging off it, which + // is what puts it over the selection when neither side had room. + const int wantedLeftPx = (side == PanelSide::Right) + ? anchorRect.right() + marginPx + 1 + : anchorRect.left() - marginPx - widthPx; // Top-aligned with the anchor, then lifted by however much of it hangs below what is - // free. Never above the band: a panel taller than the space left is capped instead, - // and scrolls. - int topPx = std::max(anchorRect.top(), band.top()); - topPx = std::min(topPx, bottomPx - heightPx + 1); - topPx = std::max(topPx, band.top()); - - return QRect(leftPx, topPx, widthPx, heightPx); + // free. + return fitInBand(band, wantedLeftPx, anchorRect.top(), wantedSize, occupiedRects, + marginPx); +} + +QRect placeAtDesiredTopLeft(const QRect& band, const QPoint& desiredTopLeftPx, + QSize wantedSize, const std::vector& occupiedRects, + int marginPx) +{ + // Where the player dropped it, resolved by the same rules as any other placement + // (REQ-UI-SELECTION-PANEL-DRAG). Nothing here is written back to the desired point: + // a panel lifted above the build button bar returns to where it was dropped as soon + // as the bar stops meeting its column. + return fitInBand(band, desiredTopLeftPx.x(), desiredTopLeftPx.y(), wantedSize, + occupiedRects, marginPx); } diff --git a/src/lib/core/FloatingPanelPlacement.h b/src/lib/core/FloatingPanelPlacement.h index fdae085..c7f743c 100644 --- a/src/lib/core/FloatingPanelPlacement.h +++ b/src/lib/core/FloatingPanelPlacement.h @@ -2,6 +2,7 @@ #include +#include #include #include @@ -41,3 +42,13 @@ PanelSide chooseSide(const QRect& band, const QRect& anchorRect, int widthPx, QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide side, QSize wantedSize, const std::vector& occupiedRects, int marginPx); + +// Where a panel of wantedSize stands once the player has dragged it to desiredTopLeftPx: +// at that point, by the same rules that place it beside a selection -- pushed inside band, +// lifted above whatever occupies its column, and capped in height where that leaves too +// little room (REQ-UI-SELECTION-PANEL-DRAG). The desired point is an input only: it is +// never corrected and handed back, which is what lets the caller keep it exactly as +// dropped and return to it once the room is there again. +QRect placeAtDesiredTopLeft(const QRect& band, const QPoint& desiredTopLeftPx, + QSize wantedSize, const std::vector& occupiedRects, + int marginPx); diff --git a/src/test/FloatingPanelPlacementTest.cpp b/src/test/FloatingPanelPlacementTest.cpp index baa3e69..b30e176 100644 --- a/src/test/FloatingPanelPlacementTest.cpp +++ b/src/test/FloatingPanelPlacementTest.cpp @@ -154,3 +154,78 @@ TEST_CASE("A panel that fits on neither side is pushed inside the view", "[layou kMarginPx); REQUIRE(placed == QRect(700, 100, 300, 200)); } + +// --------------------------------------------------------------------------- +// Where it stands once the player has dragged it (REQ-UI-SELECTION-PANEL-DRAG) +// --------------------------------------------------------------------------- + +TEST_CASE("A dragged panel stands where it was dropped", "[layout]") +{ + // With room for it there, the desired top-left corner is the answer: nothing about + // the selection it describes is consulted any more. + const QRect placed = placeAtDesiredTopLeft(makeBand(), QPoint(420, 180), + QSize(300, 200), {}, kMarginPx); + REQUIRE(placed == QRect(420, 180, 300, 200)); +} + +TEST_CASE("A panel dragged past the view's edges is pushed back inside", "[layout]") +{ + // Dropped off the right edge and below the bottom, it is brought back within the + // band, as an anchored panel is. + REQUIRE(placeAtDesiredTopLeft(makeBand(), QPoint(900, 500), QSize(300, 200), {}, + kMarginPx) + == QRect(700, 400, 300, 200)); + + // And off the left edge and above the top, the other way. + REQUIRE(placeAtDesiredTopLeft(makeBand(), QPoint(-120, -60), QSize(300, 200), {}, + kMarginPx) + == QRect(0, 0, 300, 200)); +} + +TEST_CASE("A dragged panel rises above the widget it was dropped over", "[layout]") +{ + // The player drops it over the build button bar; it lands above the bar's top by the + // margin instead, the bar staying where it is (REQ-UI-BUILD-BAR). + const std::vector occupied = { QRect(400, 520, 200, 72) }; + const QRect placed = placeAtDesiredTopLeft(makeBand(), QPoint(450, 480), + QSize(300, 200), occupied, kMarginPx); + REQUIRE(placed == QRect(450, 312, 300, 200)); +} + +TEST_CASE("A widget beside a dragged panel's column does not move it", "[layout]") +{ + // Only what the panel's own column meets is in its way: the controls panel in the + // bottom-left corner leaves a panel dropped at the right edge alone + // (REQ-UI-CONTROLS-PANEL). + const std::vector occupied = { QRect(0, 300, 260, 300) }; + const QRect placed = placeAtDesiredTopLeft(makeBand(), QPoint(600, 380), + QSize(300, 200), occupied, kMarginPx); + REQUIRE(placed == QRect(600, 380, 300, 200)); +} + +TEST_CASE("A dragged panel too tall for the room left is capped", "[layout]") +{ + // Capping is the caller's cue to scroll here too: a card asking for 700 px over a bar + // that leaves 512 px of band gets what there is, from the top of the band. + const std::vector occupied = { QRect(400, 520, 200, 72) }; + const QRect placed = placeAtDesiredTopLeft(makeBand(), QPoint(450, 100), + QSize(300, 700), occupied, kMarginPx); + REQUIRE(placed == QRect(450, 0, 300, 512)); +} + +TEST_CASE("A dragged panel returns to where it was dropped", "[layout]") +{ + // The resolution never writes back to the desired position: a panel lifted above the + // build button bar goes back to the point the player dropped it at as soon as the bar + // stops meeting its column -- here because the bar's button set shrank. + const QPoint desired(450, 380); + const std::vector wideBar = { QRect(400, 520, 200, 72) }; + REQUIRE(placeAtDesiredTopLeft(makeBand(), desired, QSize(300, 200), wideBar, + kMarginPx) + == QRect(450, 312, 300, 200)); + + const std::vector narrowBar = { QRect(400, 520, 40, 72) }; + REQUIRE(placeAtDesiredTopLeft(makeBand(), desired, QSize(300, 200), narrowBar, + kMarginPx) + == QRect(450, 380, 300, 200)); +} diff --git a/src/ui/SelectionPanel.cpp b/src/ui/SelectionPanel.cpp index e584e6a..9a51a86 100644 --- a/src/ui/SelectionPanel.cpp +++ b/src/ui/SelectionPanel.cpp @@ -1,5 +1,6 @@ #include "SelectionPanel.h" +#include #include #include #include @@ -103,6 +104,56 @@ void SelectionPanel::invalidateLayout() std::make_shared()); } +void SelectionPanel::mousePressEvent(QMouseEvent* event) +{ + // Only the card's header moves the panel (REQ-UI-SELECTION-PANEL-DRAG). Presses on + // the header's labels arrive here by ordinary propagation, none of them being a + // widget that accepts mouse events, and carry a position in this panel's coordinates. + if (event->button() == Qt::LeftButton && m_content != nullptr) + { + QWidget* header = m_content->getHeaderWidget(); + QRect headerRect(header->mapTo(this, QPoint(0, 0)), header->size()); + // A card long enough to scroll can have its header scrolled out of sight; what is + // hidden is not a handle, and the pixels it would claim show other parts of the + // card. + QWidget* viewport = m_scrollArea->viewport(); + headerRect &= QRect(viewport->mapTo(this, QPoint(0, 0)), viewport->size()); + if (headerRect.contains(event->pos())) + { + m_dragGrabOffsetPx = event->pos(); + } + } + + // Accepted whether or not it started a drag: no mouse event over the panel reaches + // the game world (REQ-UI-SELECTION-PANEL). + event->accept(); +} + +void SelectionPanel::mouseMoveEvent(QMouseEvent* event) +{ + if (m_dragGrabOffsetPx.has_value() && parentWidget() != nullptr) + { + // Read from the cursor's position on the screen rather than from the panel's own + // coordinates, which move under the cursor as the drag places the panel again. + const QPoint topLeftPx = + parentWidget()->mapFromGlobal(event->globalPos()) - *m_dragGrabOffsetPx; + m_desiredTopLeftPx = topLeftPx - m_viewOriginPx; + // Where the panel actually lands follows from the desired position by the + // ordinary rules, the widgets it steps around included (REQ-UI-SELECTION-PANEL-DRAG). + invalidateLayout(); + } + event->accept(); +} + +void SelectionPanel::mouseReleaseEvent(QMouseEvent* event) +{ + // Qt's implicit grab has kept the drag alive while the cursor was outside the panel + // or outside the view; it ends here, wherever the button was let go + // (REQ-UI-SELECTION-PANEL-DRAG). The desired position stays as dropped. + m_dragGrabOffsetPx.reset(); + event->accept(); +} + void SelectionPanel::handleEvent(std::shared_ptr event) { m_request.buildings = event->ids; @@ -126,6 +177,13 @@ void SelectionPanel::handleEvent( // panel is placed, the two widgets being siblings in the same parent. m_anchorRect = event->rectPx; m_side.reset(); + // A position the player dragged the panel to belongs to the selection it was set in. + // A new selection places the panel anew against its own anchor + // (REQ-UI-SELECTION-PANEL-DRAG); this event is published only when one starts, and + // not when an existing selection is added to or reduced, which is exactly the scope + // the dragged position keeps. + m_desiredTopLeftPx.reset(); + m_dragGrabOffsetPx.reset(); } void SelectionPanel::handleEvent( @@ -238,6 +296,11 @@ void SelectionPanel::placeIn(const QRect& viewRect, const std::vector& oc const QRect band = viewRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx); + // What the view's coordinates and this panel's differ by, kept for the drag gesture, + // which learns of the cursor in the latter and stores its result in the former + // (REQ-UI-SELECTION-PANEL-DRAG). + m_viewOriginPx = viewRect.topLeft(); + // The anchor is published in the world view's coordinates and this panel is placed in // its parent's; the two widgets are siblings, so the view's own origin is the whole // difference. Without an anchor the panel falls back to the top-right corner, by @@ -278,6 +341,22 @@ void SelectionPanel::placeIn(const QRect& viewRect, const std::vector& oc return m_body->sizeHint(); }; + // Where a panel of that size stands: beside the selection, or at the position the + // player dragged the panel to, which replaces both the anchor and the side for the + // rest of the selection (REQ-UI-SELECTION-PANEL-DRAG). Either way the result is + // resolved against the view's edges and the widgets placed before this one, so a + // dragged panel steps around them exactly as an anchored one does. + auto solve = [&](QSize wantedSize) -> QRect + { + if (m_desiredTopLeftPx.has_value()) + { + return placeAtDesiredTopLeft(band, *m_desiredTopLeftPx + viewRect.topLeft(), + wantedSize, occupiedRects, kMarginPx); + } + return placeBesideAnchor(band, anchorRect, *m_side, wantedSize, occupiedRects, + kMarginPx); + }; + // Run twice. Parts of a card report an unstyled size until the style has actually // reached them, which for a freshly built card happens during the first round of // measuring; the second round then measures a card that is fully laid out and @@ -307,9 +386,8 @@ void SelectionPanel::placeIn(const QRect& viewRect, const std::vector& oc // pass settles it again against the wider column. Asking for the whole band's // height is what makes the answer the most the panel could have there. const int maxHeightPx = - placeBesideAnchor(band, anchorRect, *m_side, - QSize(contentWidthPx + 2 * borderPx, band.height()), - occupiedRects, kMarginPx).height() - 2 * borderPx; + solve(QSize(contentWidthPx + 2 * borderPx, band.height())).height() + - 2 * borderPx; if (maxHeightPx <= 0) { return; @@ -343,8 +421,6 @@ void SelectionPanel::placeIn(const QRect& viewRect, const std::vector& oc const int panelWidthPx = contentWidthPx + 2 * borderPx; const int panelHeightPx = contentHeightPx + 2 * borderPx; - setGeometry(placeBesideAnchor(band, anchorRect, *m_side, - QSize(panelWidthPx, panelHeightPx), - occupiedRects, kMarginPx)); + setGeometry(solve(QSize(panelWidthPx, panelHeightPx))); } } diff --git a/src/ui/SelectionPanel.h b/src/ui/SelectionPanel.h index 0bffd28..3223640 100644 --- a/src/ui/SelectionPanel.h +++ b/src/ui/SelectionPanel.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -25,6 +26,7 @@ class BuildingIconCache; class ItemIconCache; class SelectionContent; class Simulation; +class QMouseEvent; class QScrollArea; class QVBoxLayout; @@ -64,6 +66,14 @@ public: void placeIn(const QRect& viewRect, const std::vector& occupiedRects) override; +protected: + // The panel is moved by dragging its card header (REQ-UI-SELECTION-PANEL-DRAG). Every + // other mouse event over the panel is swallowed here so that none of them reaches the + // game world beneath (REQ-UI-SELECTION-PANEL). + void mousePressEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + private: void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; @@ -98,10 +108,23 @@ private: // scrolling view or a moving ship, the side because a card that grows must not flip // the panel across the object (REQ-UI-SELECTION-PANEL). The side is resolved on the // first placement after a new anchor, being the first point at which the panel's - // width is known. + // width is known. Dragging the panel supersedes the pair for the rest of the + // selection (REQ-UI-SELECTION-PANEL-DRAG). QRect m_anchorRect; std::optional m_side; + // Where the player dragged the panel, in the game world view's coordinates, and the + // cursor's offset within the panel while a drag is running + // (REQ-UI-SELECTION-PANEL-DRAG). The desired position is kept exactly as dropped: + // resolving it against the view's edges and the widgets the panel steps around never + // writes back to it, so the panel returns to it once the room is there again. It + // lasts as long as the selection it was set in. + std::optional m_desiredTopLeftPx; + std::optional m_dragGrabOffsetPx; + // Origin of the view the panel was last placed in, which is what the two coordinate + // systems differ by -- the panel is a sibling of the view, not a child of it. + QPoint m_viewOriginPx; + // Scrolls the card once it outgrows the space the panel has (REQ-UI-SELECTION-PANEL). // The card is a child of m_body, not of the panel itself. QScrollArea* m_scrollArea; diff --git a/src/ui/selection/SelectionContent.cpp b/src/ui/selection/SelectionContent.cpp index 2831f8d..c85cc59 100644 --- a/src/ui/selection/SelectionContent.cpp +++ b/src/ui/selection/SelectionContent.cpp @@ -50,26 +50,30 @@ SelectionContent::SelectionContent(const SelectionContext& context, // Header: identity symbol, name, and the right slot pushed to the far edge // (REQ-UI-SELECTION-CARD). - QWidget* header = new QWidget(this); - QHBoxLayout* headerLayout = new QHBoxLayout(header); + m_header = new QWidget(this); + // The header doubles as the panel's drag handle (REQ-UI-SELECTION-PANEL-DRAG); the + // cursor says so, as the controls panel's heading does for the click that collapses + // it. The gesture itself belongs to SelectionPanel, which owns the placement. + m_header->setCursor(Qt::SizeAllCursor); + QHBoxLayout* headerLayout = new QHBoxLayout(m_header); headerLayout->setContentsMargins(0, 0, 0, 0); headerLayout->setSpacing(kHeaderSpacingPx); - m_symbolLabel = new QLabel(header); + m_symbolLabel = new QLabel(m_header); m_symbolLabel->hide(); - m_nameLabel = new QLabel(header); + m_nameLabel = new QLabel(m_header); QFont nameFont = m_nameLabel->font(); nameFont.setBold(true); m_nameLabel->setFont(nameFont); - m_statusPill = new StatusPill(header); + m_statusPill = new StatusPill(m_header); headerLayout->addWidget(m_symbolLabel); headerLayout->addWidget(m_nameLabel); headerLayout->addStretch(1); headerLayout->addWidget(m_statusPill); - cardLayout->addWidget(header); + cardLayout->addWidget(m_header); m_configurationGroup = new QWidget(this); QVBoxLayout* configurationLayout = new QVBoxLayout(m_configurationGroup); diff --git a/src/ui/selection/SelectionContent.h b/src/ui/selection/SelectionContent.h index 6d8139a..5fb2b5f 100644 --- a/src/ui/selection/SelectionContent.h +++ b/src/ui/selection/SelectionContent.h @@ -45,6 +45,11 @@ public: // SelectionPanel owns that decision. void refresh(); + // The card's header (REQ-UI-SELECTION-CARD), which is also the handle the panel is + // dragged by (REQ-UI-SELECTION-PANEL-DRAG). The panel hit-tests against it; the card + // itself does nothing with the gesture. + QWidget* getHeaderWidget() const { return m_header; } + protected: // constructionSiteId is set only while the card shows a construction site, in which // case this base builds and drives the construction section in place of whatever the @@ -91,6 +96,7 @@ private: // Set while this card shows a construction site rather than a finished object. std::optional m_siteId; + QWidget* m_header; QLabel* m_symbolLabel; QLabel* m_nameLabel; StatusPill* m_statusPill;