diff --git a/docs/requirements.md b/docs/requirements.md index 872f254..1f2ade1 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -310,7 +310,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro The dialog contains: - **Top**: The ship's layout grid rendered at full scale. Buildable cells are white; non-buildable cells are black. Placed modules are rendered with their `fill_color` and `glyph`. The ghost of the currently selected module is shown at the cursor position when in placement mode. - - **Left** (below the grid): The ship stats panel (see REQ-MOD-UI-STATS-PANEL). + - **Left** (below the grid): The ship stats panel (see REQ-MOD-UI-STATS-PANEL), and beneath it the layout's **build cost**, so the price of a configuration is visible while it is being assembled rather than only once it has been confirmed: the total materials required for the ship — the union of the schematic's base materials and those of every placed module, summed per item type (REQ-MOD-MATERIALS) — drawn as item icons on their colored squares with their amounts (REQ-UI-ITEM-ICON), and beside them the total production time (REQ-MOD-PRODUCTION-TIME). Both update in real time as modules are placed and removed. The cost sits beside the stats panel rather than within it because a build cost belongs to a ship being configured: the same panel serves an existing ship (REQ-UI-SHIP-STATS-PANEL) and the balancing tool, neither of which costs anything. - **Center** (below the grid): A grid of module selection buttons (one per **unlocked** module type; see REQ-DEF-SCHEMATIC-DROP) plus a "Remove" button. A module button shows the module's name — its `id` under the usual display convention — and its glyph on its first line, and beneath it what the module costs: the icons of its `materials` items on their colored squares with their amounts (REQ-UI-ITEM-ICON) and the production time it adds, as `+ s` (REQ-MOD-MATERIALS, REQ-MOD-PRODUCTION-TIME). This is the same form a recipe option button uses to state what it makes (REQ-UI-SELECT-OPTIONS), and it puts the price of a module in front of the player before it is placed. The "Remove" button costs nothing and shows its caption alone. - **Right** (below the grid): The layout blueprint panel (see REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). - **Bottom**: A "Confirm" button and a "Cancel" button. Cancel discards all changes made in this dialog session and closes the dialog. Confirm applies the changes: the shipyard's configured layout is updated, the required materials and cycle time displayed in the selection panel are recalculated, and the ship layout preview is refreshed. @@ -340,8 +340,6 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro All capability module stat values incorporate passive modifiers targeting the relevant capability category per REQ-MOD-STAT-CALC. - The panel also shows the layout's **build cost**, so the price of a configuration is visible while it is being assembled rather than only once it has been confirmed: the total materials required for the ship — the union of the schematic's base materials and those of every placed module, summed per item type (REQ-MOD-MATERIALS) — drawn as item icons on their colored squares with their amounts (REQ-UI-ITEM-ICON), and beside them the total production time (REQ-MOD-PRODUCTION-TIME). Both update in real time as modules are placed and removed, as every other value in this panel does. A build cost belongs to a ship being configured, so it appears here only: the selection panel's stats panel for an existing ship (REQ-UI-SHIP-STATS-PANEL) shows none. - While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT) for the current layout configuration. This value updates in real time as modules are placed or removed. - REQ-MOD-UI-LAYOUT-SIZE: Ship layouts are small enough to display in the layout configuration dialog without scrolling (maximum grid size fits within the dialog). @@ -596,7 +594,7 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE - REQ-UI-MULTI-SELECTION: When multiple buildings are selected and the selection does not aggregate (REQ-UI-SELECTION-AGGREGATE), the panel shows a count summary. Its header names the size of the selection as ` buildings` in place of an object name, and carries no symbol and nothing in its right slot. Below it is one row per selected building type — the type's symbol, its name, and the number selected as `x` — one type per row, and no per-building detail. A final row shows the **total building block cost** of the selection, captioned `Total cost` with the value followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON): the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button bar); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-CARD). Construction sites count at their building type's full placement cost regardless of construction progress. - REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel, in its configuration group (REQ-UI-SELECTION-CARD). Recipe selection (miner, assembler) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW). - REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selection panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened) and that opens centered on the selection panel, as every modal opened from the panel does (REQ-UI-PANEL-MODAL). The dialog contains a vertical list of option buttons, one per selectable option, each describing itself (REQ-UI-SELECT-OPTIONS) — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Neither the option buttons nor the selection button carries a tooltip: an option button states what it makes on its own face, and what the building has selected is drawn beneath the selection button as the recipe summary (REQ-UI-RECIPE-SUMMARY), with the production paths of the items involved reachable by hovering the card's item chips (REQ-UI-ITEM-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selection panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD). -- REQ-UI-SELECT-OPTIONS: **Option list of the selection dialog.** The selection dialog (REQ-UI-SELECT-BUTTON) lists its options as a **single vertical column** of buttons, one per option, rather than a grid: each button is as wide as the dialog and states what the option does, which needs a line of its own. No option button carries a tooltip — the button face is the whole description, so the player reads every option's inputs, product, and time from the list itself without hovering anything. +- REQ-UI-SELECT-OPTIONS: **Option list of the selection dialog.** The selection dialog (REQ-UI-SELECT-BUTTON) lists its options as a **single vertical column** of buttons, one per option, rather than a grid: each button is as wide as the dialog and states what the option does, which needs a line of its own. No option button carries a tooltip — the button face is the whole description, so the player reads every option's inputs, product, and time from the list itself without hovering anything. Should the column be taller than the space the window leaves the dialog — a fully unlocked Assembler offers more options than any window can hold, and a modal is never resized to fit (REQ-UI-PANEL-MODAL) — the list **scrolls** within the dialog rather than the dialog growing past the window. - A **recipe** option (Miner, Assembler) shows the **recipe name** on its first line and, beneath it, that recipe drawn as the recipe summary line draws it (REQ-UI-RECIPE-SUMMARY): each input item's icon on its colored square with its per-cycle amount, an arrow, each output item's icon with its amount, and the cycle time. A miner recipe consumes nothing, so its line begins at the arrow. - A **ship schematic** option (Shipyard) shows the ship's `display_name` on its first line and, beneath it, the icons and quantities of its base required materials (`[ship.schematic].materials`, excluding any module contributions) with the base production time (`[ship.schematic].production_time_seconds`). A ship is not an item and has no icon of its own, so this line names no output: the button's caption is what it produces. - The `(None)` option shows its text caption alone. @@ -604,7 +602,7 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE - the heading is the **hovered item's name**. For an input chip this is the only place the item is named at all, since such a chip carries a count and no name (REQ-UI-SINGLE-SELECTION). - the body is the caption `Produced by` followed by one **recipe line** per unlocked recipe that produces the item. Each line carries the icon of the building that runs that recipe (REQ-UI-BUILD-ICON), the recipe's name, and the recipe drawn as the recipe summary line draws it (REQ-UI-RECIPE-SUMMARY): each input item's icon on its colored square with its per-cycle amount, an arrow, each output item's icon with its amount, and the cycle time. An item may have several producers — an iron ingot is smelted from ore, smelted from scrap, and recovered by reprocessing — and the building icon and recipe name are what tell those lines apart and tell the player which building to place for which path. - **Only unlocked recipes are listed** (REQ-LOCK-UI-RECIPE), consistent with the rest of the UI hiding what the player cannot make yet. Two cases have no recipe line to show, and each says so in place of the list rather than leaving the tooltip bare: + **Only recipes the player can run are listed**, consistent with the rest of the UI hiding what the player cannot make yet. What that means differs by building, because only Miner and Assembler recipes are unlocked individually (REQ-LOCK-UI-RECIPE): those are listed once unlocked, while a Smelter's or Reprocessing Plant's implicit recipes (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are listed once **their building** is unlocked (REQ-LOCK-BUILDING) — there is no sense in naming a path through a plant the player cannot place. Two cases have no recipe line to show, and each says so in place of the list rather than leaving the tooltip bare: - **An item no recipe produces at all.** Scrap is salvaged from debris (REQ-RES-DEBRIS-DROP) rather than crafted, so its tooltip reads `Salvaged from debris` in place of the `Produced by` caption and lists nothing beneath it. - **An item whose every producing recipe is still locked.** The caption stays `Produced by`, and the single line beneath it reads `Undiscovered`: the player is told the item is made somehow, without being shown a path they have not unlocked yet. diff --git a/src/lib/config/ConfigLoaderRecipes.cpp b/src/lib/config/ConfigLoaderRecipes.cpp index 29ea3a3..fda1aa2 100644 --- a/src/lib/config/ConfigLoaderRecipes.cpp +++ b/src/lib/config/ConfigLoaderRecipes.cpp @@ -94,14 +94,6 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path) const toml::array& outputs = utility::requireArray(mt["outputs"], file, elemPath + ".outputs"); def.outputs = parseRecipeOutputs(outputs, file, elemPath + ".outputs"); - // Optional icon item id (REQ-UI-RECIPE-ICON); defaults to the first output - // in the UI when unset. Not validated against known items here — a missing - // icon is not an error (REQ-UI-ITEM-ICON). - if (mt.contains("icon")) - { - def.icon = utility::requireString(mt["icon"], file, elemPath + ".icon"); - } - cfg.recipes.push_back(std::move(def)); } diff --git a/src/lib/config/RecipesConfig.h b/src/lib/config/RecipesConfig.h index 08fdcc1..9bda9b8 100644 --- a/src/lib/config/RecipesConfig.h +++ b/src/lib/config/RecipesConfig.h @@ -32,10 +32,6 @@ struct RecipeDef std::vector inputs; std::vector outputs; double durationSeconds; - // Optional id of the item whose icon represents this recipe in the recipe- - // selection dialog (REQ-UI-RECIPE-ICON). When unset, the first output item is - // used. A missing icon file for that item is not an error (REQ-UI-ITEM-ICON). - std::optional icon; // Assembler only. When true, this recipe is available from game start // regardless of the implicit item graph — used for base recipes that no // schematic's materials reach (e.g. building blocks). See REQ-LOCK-IMPLICIT. diff --git a/src/lib/sim/ProductionRules.cpp b/src/lib/sim/ProductionRules.cpp index 4812084..7260c76 100644 --- a/src/lib/sim/ProductionRules.cpp +++ b/src/lib/sim/ProductionRules.cpp @@ -81,6 +81,33 @@ computeShipyardRequiredMaterials(const GameConfig& config, } return requiredMaterials; } + +double computeShipyardProductionTimeSeconds( + const GameConfig& config, const std::string& recipeId, + const std::optional& shipLayout) +{ + const ShipDef* shipDef = config.ships.findShipDef(recipeId); + if (!shipDef) + { + return 0.0; + } + + double seconds = shipDef->schematic.productionTimeSeconds; + if (shipLayout.has_value()) + { + for (const PlacedModule& pm : shipLayout->placedModules) + { + const ModuleDef* modDef = config.modules.findModuleDef(pm.moduleId); + if (!modDef) + { + continue; + } + seconds += modDef->productionTimeSeconds; + } + } + return seconds; +} + bool hasInputsToStart(const GameConfig& config, const Building& b) { if (b.type == BuildingType::Shipyard) diff --git a/src/lib/sim/ProductionRules.h b/src/lib/sim/ProductionRules.h index 78e69c1..8be66f2 100644 --- a/src/lib/sim/ProductionRules.h +++ b/src/lib/sim/ProductionRules.h @@ -44,6 +44,12 @@ std::map computeShipyardRequiredMaterials( const GameConfig& config, const std::string& recipeId, const std::optional& shipLayout); +// The ship's base production time plus that of every module in the configured layout +// (REQ-MOD-PRODUCTION-TIME), over a stored configuration as above. +double computeShipyardProductionTimeSeconds( + const GameConfig& config, const std::string& recipeId, + const std::optional& shipLayout); + // True when a production cycle could start right now, ignoring output-buffer space. bool hasInputsToStart(const GameConfig& config, const Building& b); diff --git a/src/test/ConfigLoaderTest.cpp b/src/test/ConfigLoaderTest.cpp index ab3b875..bfdf4bf 100644 --- a/src/test/ConfigLoaderTest.cpp +++ b/src/test/ConfigLoaderTest.cpp @@ -390,39 +390,6 @@ duration_seconds = 1.0 std::runtime_error); } -TEST_CASE("Optional recipe icon field parses; absent leaves it unset", "[config]") -{ - TempConfigDir dir; - writeFile(dir.path() / "recipes.toml", R"( -[[recipe]] -id = "with_icon" -building = "assembler" -inputs = [{item = "iron_ingot", amount = 1}] -outputs = [{item = "steel_plate", amount = 1}] -duration_seconds = 1.0 -icon = "hardened_steel" - -[[recipe]] -id = "without_icon" -building = "assembler" -inputs = [{item = "iron_ingot", amount = 1}] -outputs = [{item = "copper_wire", amount = 1}] -duration_seconds = 1.0 -)"); - - const RecipesConfig cfg = - ConfigLoader::loadRecipes((dir.path() / "recipes.toml").string()); - - const RecipeDef& withIcon = cfg.recipes.at(0); - REQUIRE(withIcon.id == "with_icon"); - REQUIRE(withIcon.icon.has_value()); - REQUIRE(*withIcon.icon == "hardened_steel"); - - const RecipeDef& withoutIcon = cfg.recipes.at(1); - REQUIRE(withoutIcon.id == "without_icon"); - REQUIRE_FALSE(withoutIcon.icon.has_value()); -} - // --- unlock_requires (REQ-LOCK-PREREQ) ------------------------------------ namespace diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index b16063d..ab69eea 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -25,7 +25,8 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h - ${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.h + ${CMAKE_CURRENT_SOURCE_DIR}/RecipeLineRow.h + ${CMAKE_CURRENT_SOURCE_DIR}/ItemProducers.h ${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingIconCache.h ${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.h @@ -59,7 +60,8 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/RecipeLineRow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ItemProducers.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingIconCache.cpp ${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.cpp diff --git a/src/ui/ItemIconCache.cpp b/src/ui/ItemIconCache.cpp index 7ae6b41..d7e6991 100644 --- a/src/ui/ItemIconCache.cpp +++ b/src/ui/ItemIconCache.cpp @@ -114,7 +114,7 @@ QPixmap ItemIconCache::getPixmap(const std::string& cacheKey, const std::string& if (withSquare) { // Null unless the item has something to draw -- a square, an icon, or both -- - // so a caller with nothing to show can fall back to text (REQ-UI-RECIPE-ICON). + // so a caller with nothing to show can fall back to text (REQ-UI-ITEM-ICON). const bool hasSquare = m_visuals != nullptr && m_visuals->items.find(itemId) != m_visuals->items.end(); if (hasSquare || hasIcon(itemId)) diff --git a/src/ui/ItemIconCache.h b/src/ui/ItemIconCache.h index 6f2dc5a..6552d93 100644 --- a/src/ui/ItemIconCache.h +++ b/src/ui/ItemIconCache.h @@ -18,9 +18,11 @@ struct VisualsConfig; // // The square is the item's `fill` and `outline` from visuals.toml with the icon inset // within it, and it backs the icon both in the game world and wherever the UI displays -// an item as an item: the recipe-selection dialog's option buttons (REQ-UI-RECIPE-ICON), -// the selection panel's item chips (REQ-UI-SINGLE-SELECTION, REQ-UI-HQ-PANEL), and the -// recipe summary (REQ-UI-RECIPE-SUMMARY). Defined here once rather than per widget, in +// an item as an item: the selection panel's item chips (REQ-UI-SINGLE-SELECTION, +// REQ-UI-HQ-PANEL), and every recipe drawn as a line -- the recipe summary, the +// selection dialog's option buttons, the item production tooltip +// (REQ-UI-RECIPE-SUMMARY, REQ-UI-SELECT-OPTIONS, REQ-UI-ITEM-TOOLTIP). Defined here +// once rather than per widget, in // two forms: paintItem() for the world's fractional geometry, getSquarePixmap() for // widgets that want a ready-made pixmap. // diff --git a/src/ui/ItemProducers.cpp b/src/ui/ItemProducers.cpp new file mode 100644 index 0000000..7fc060e --- /dev/null +++ b/src/ui/ItemProducers.cpp @@ -0,0 +1,60 @@ +#include "ItemProducers.h" + +#include "GameConfig.h" +#include "RecipesConfig.h" +#include "Simulation.h" + +namespace +{ + +bool producesItem(const RecipeDef& recipe, const std::string& itemId) +{ + for (const RecipeOutput& output : recipe.outputs) + { + if (output.item == itemId) + { + return true; + } + } + return false; +} + +bool isAvailable(const RecipeDef& recipe, const Simulation& sim) +{ + if (recipe.building == BuildingType::Miner + || recipe.building == BuildingType::Assembler) + { + return sim.isRecipeUnlocked(recipe.id); + } + return sim.isBuildingUnlocked(recipe.building); +} + +} // namespace + +ItemProduction findItemProduction(const std::string& itemId, const Simulation& sim, + const GameConfig& config) +{ + ItemProduction result; + + bool anyProducer = false; + for (const RecipeDef& recipe : config.recipes.recipes) + { + if (!producesItem(recipe, itemId)) { continue; } + + anyProducer = true; + if (isAvailable(recipe, sim)) + { + result.recipes.push_back(&recipe); + } + } + + if (!result.recipes.empty()) + { + result.origin = ItemOrigin::Produced; + } + else + { + result.origin = anyProducer ? ItemOrigin::Undiscovered : ItemOrigin::Salvaged; + } + return result; +} diff --git a/src/ui/ItemProducers.h b/src/ui/ItemProducers.h new file mode 100644 index 0000000..66c3277 --- /dev/null +++ b/src/ui/ItemProducers.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +struct GameConfig; +struct RecipeDef; +class Simulation; + +// Where an item comes from, as far as the player has discovered (REQ-UI-ITEM-TOOLTIP). +enum class ItemOrigin +{ + Produced, // at least one recipe the player can run makes it + Undiscovered, // recipes make it, but none of them is available yet + Salvaged, // no recipe makes it at all -- scrap, which comes from debris +}; + +struct ItemProduction +{ + ItemOrigin origin = ItemOrigin::Salvaged; + // The available producers, in config order. Empty unless origin is Produced. + std::vector recipes; +}; + +// Every way the player can currently produce the given item, for the tooltip that +// explains an item chip (REQ-UI-ITEM-TOOLTIP). +// +// What counts as available differs by building type, because only Miner and Assembler +// recipes are individually unlocked (REQ-LOCK-UI-RECIPE): those are filtered by the +// unlock state, while a Smelter's or Reprocessing Plant's implicit recipes +// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are filtered by whether their building is +// unlocked yet (REQ-LOCK-BUILDING) -- there is no sense in naming a path through a +// plant the player cannot place. +ItemProduction findItemProduction(const std::string& itemId, const Simulation& sim, + const GameConfig& config); diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 3f736f6..e8d27b4 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -224,7 +224,8 @@ void MainWindow::handleEvent(std::shared_ptrchoices, m_sim->getConfig().recipes, this); + SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes, + m_itemIcons.get(), m_buildingIcons.get(), this); dialog.exec(); std::shared_ptr command = @@ -317,7 +318,7 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId, m_layoutBlueprints, std::move(unlockedModuleIds), m_gameWorldView->isDebugDrawEnabled(), - this); + m_itemIcons.get(), this); // Opened from the panel's "Configure" button (REQ-MOD-UI-PREVIEW) or straight after // a schematic change (REQ-MOD-UI-AUTO-DIALOG), so it opens on the panel either way. placeOnSelectionPanel(dialog); @@ -427,7 +428,8 @@ void MainWindow::handleEvent(std::shared_ptr +#include +#include +#include + +#include "BuildingIconCache.h" +#include "ItemIconCache.h" + +namespace +{ + +// Size the items' colored squares and the building's chip are drawn at on a line, in +// device-independent pixels. Larger than the artwork they carry, which the square +// insets (REQ-UI-ITEM-ICON). +const int kIconSizePx = 18; + +// Adds a freshly built label to the line and shows it. +// +// The show is what makes it count: a widget created under an already-visible parent +// starts hidden, and a layout treats a hidden item as empty, so an unshown label would +// add nothing to the size hint the surrounding widget measures itself against as soon +// as this returns. +void addAndShow(QHBoxLayout* layout, QLabel* label) +{ + layout->addWidget(label); + label->show(); +} + +} // namespace + + +RecipeLineRow::RecipeLineRow(ItemIconCache* itemIcons, BuildingIconCache* buildingIcons, + QWidget* parent) + : QWidget(parent) + , m_itemIcons(itemIcons) + , m_buildingIcons(buildingIcons) +{ + m_layout = new QHBoxLayout(this); + m_layout->setContentsMargins(0, 0, 0, 0); + m_layout->setSpacing(4); + + hide(); +} + +void RecipeLineRow::setLine(const Spec& spec) +{ + if (spec.isEmpty()) + { + // Forgotten as well as hidden, so re-selecting the same recipe later is seen as + // a change and shows the row again. + m_spec = Spec(); + hide(); + return; + } + if (spec == m_spec) + { + return; + } + + m_spec = spec; + rebuild(spec); + show(); +} + +void RecipeLineRow::rebuild(const Spec& spec) +{ + while (QLayoutItem* item = m_layout->takeAt(0)) + { + if (item->widget()) + { + item->widget()->deleteLater(); + } + delete item; + } + + // The building the recipe runs in, where the line has to distinguish one producer + // from another (REQ-UI-ITEM-TOOLTIP). A building with no chip file leaves the icon + // off, as everywhere else (REQ-UI-BUILD-ICON). + if (spec.building.has_value() && m_buildingIcons != nullptr) + { + const QPixmap chip = + m_buildingIcons->getChip(buildingTypeId(*spec.building), kIconSizePx); + if (!chip.isNull()) + { + QLabel* chipLabel = new QLabel(this); + chipLabel->setPixmap(chip); + addAndShow(m_layout, chipLabel); + } + } + if (!spec.name.isEmpty()) + { + addAndShow(m_layout, new QLabel(spec.name, this)); + } + + addAmounts(spec.inputs); + if (!spec.inputs.empty() && !spec.outputs.empty()) + { + const QChar rightArrow(0x2192); // U+2192 RIGHTWARDS ARROW + addAndShow(m_layout, new QLabel(QString(rightArrow), this)); + } + addAmounts(spec.outputs); + + if (spec.durationSeconds.has_value() && *spec.durationSeconds > 0.0) + { + const QChar middleDot(0x00B7); // U+00B7 MIDDLE DOT + const QString time = spec.durationIsAddition + ? tr("+%1 s").arg(*spec.durationSeconds, 0, 'f', 1) + : tr("%1 s").arg(*spec.durationSeconds, 0, 'f', 1); + addAndShow(m_layout, new QLabel( + QStringLiteral("%1 %2").arg(middleDot).arg(time), this)); + } + m_layout->addStretch(1); +} + +void RecipeLineRow::addAmounts(const std::vector& amounts) +{ + for (const Amount& entry : amounts) + { + // The item's icon on its colored square (REQ-UI-ITEM-ICON). A missing icon file + // is not an error: the square stands alone then, and only an item with no square + // either falls back to its id in text. + const QPixmap icon = (m_itemIcons != nullptr) + ? m_itemIcons->getSquarePixmap(entry.itemId, kIconSizePx) + : QPixmap(); + if (!icon.isNull()) + { + QLabel* iconLabel = new QLabel(this); + iconLabel->setPixmap(icon); + addAndShow(m_layout, iconLabel); + } + else + { + addAndShow(m_layout, + new QLabel(QString::fromStdString(entry.itemId), this)); + } + addAndShow(m_layout, new QLabel(QString::number(entry.amount), this)); + } +} diff --git a/src/ui/RecipeLineRow.h b/src/ui/RecipeLineRow.h new file mode 100644 index 0000000..5457a9e --- /dev/null +++ b/src/ui/RecipeLineRow.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include "BuildingType.h" + +class BuildingIconCache; +class ItemIconCache; +class QHBoxLayout; + +// One recipe drawn on a line: what it consumes, an arrow, what it produces, and how +// long a cycle takes (REQ-UI-RECIPE-SUMMARY). Every place the UI states what something +// makes draws this same line, so a recipe reads alike wherever it is shown: +// +// * beneath the selection button, for the recipe a building is running +// (REQ-UI-RECIPE-SUMMARY); +// * on a selection dialog's option buttons, under the option's name +// (REQ-UI-SELECT-OPTIONS); +// * in an item's production tooltip and in the unlock-choice dialog, there led by the +// icon of the building that runs the recipe and its name +// (REQ-UI-ITEM-TOOLTIP, REQ-DEF-SCHEMATIC-DROP); +// * on a module button and in the layout dialog's build cost, where there is a price +// and a time but nothing produced (REQ-MOD-UI-DIALOG, REQ-MOD-UI-STATS-PANEL). +class RecipeLineRow : public QWidget +{ + Q_OBJECT + +public: + // One item and how many of it the line states. + struct Amount + { + std::string itemId; + int amount = 0; + + bool operator==(const Amount& other) const + { + return itemId == other.itemId && amount == other.amount; + } + }; + + // What the line says. Everything but the inputs is optional, which is what lets the + // one widget serve the summary, the option buttons, the tooltip lines and the cost + // lines. + struct Spec + { + // Drawn as a leading chip when the line has to say which building runs the + // recipe -- an item may have several producers (REQ-UI-ITEM-TOOLTIP). Unset + // wherever the surrounding widget already establishes the building. + std::optional building; + // Leading recipe name, for the same reason. Empty where the name is the + // caption above the line instead. + QString name; + std::vector inputs; + // Empty for a line that produces no item of its own: a ship schematic, or a + // module's price. No arrow is drawn then. + std::vector outputs; + std::optional durationSeconds; + // True where the time is added to something else rather than being a cycle of + // its own, and so reads "+3.0 s" (REQ-MOD-UI-DIALOG). + bool durationIsAddition = false; + + bool operator==(const Spec& other) const + { + return building == other.building && name == other.name + && inputs == other.inputs && outputs == other.outputs + && durationSeconds == other.durationSeconds + && durationIsAddition == other.durationIsAddition; + } + + bool isEmpty() const { return inputs.empty() && outputs.empty(); } + }; + + // Both caches may be null, which leaves the icons off: an item with no square and + // no icon falls back to its id, and a building with no chip to its name alone. + // Neither is owned. + RecipeLineRow(ItemIconCache* itemIcons, BuildingIconCache* buildingIcons, + QWidget* parent = nullptr); + + // Hides the row when the spec has nothing to state, which is how a building with no + // recipe shows no summary at all (REQ-UI-RECIPE-SUMMARY). + void setLine(const Spec& spec); + +private: + void rebuild(const Spec& spec); + void addAmounts(const std::vector& amounts); + + ItemIconCache* m_itemIcons; + BuildingIconCache* m_buildingIcons; + QHBoxLayout* m_layout; + // What the row currently shows, so a refresh at tick rate rebuilds it only when the + // recipe actually changed. + Spec m_spec; +}; diff --git a/src/ui/RecipeSelectionDialog.cpp b/src/ui/RecipeSelectionDialog.cpp index 096a5f4..c7189b4 100644 --- a/src/ui/RecipeSelectionDialog.cpp +++ b/src/ui/RecipeSelectionDialog.cpp @@ -2,60 +2,45 @@ #include -#include -#include -#include +#include +#include +#include #include -#include -#include +#include #include #include "Building.h" #include "BuildingType.h" #include "DisplayName.h" #include "GameConfig.h" -#include "ItemIconCache.h" #include "RecipesConfig.h" -#include "RecipeTooltip.h" #include "ShipsConfig.h" #include "Simulation.h" namespace { -QString itemLine(const std::string& itemId, int amount) +std::vector toAmounts( + const std::vector& ingredients) { - return QStringLiteral(" ") - + QString::fromStdString(toDisplayName(itemId)) - + QStringLiteral(" x ") + QString::number(amount); + std::vector amounts; + amounts.reserve(ingredients.size()); + for (const RecipeIngredient& ingredient : ingredients) + { + amounts.push_back(RecipeLineRow::Amount{ ingredient.item, ingredient.amount }); + } + return amounts; } -QString shipTooltip(const ShipDef& def) +std::vector toAmounts(const std::vector& outputs) { - const QString name = QString::fromStdString(toDisplayName(def.id)); - - QStringList lines; - lines << QObject::tr("Ship: %1").arg(name); - - if (def.schematic.materials.empty()) + std::vector amounts; + amounts.reserve(outputs.size()); + for (const RecipeOutput& output : outputs) { - lines << QObject::tr("Materials: none"); + amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount }); } - else - { - lines << QObject::tr("Materials:"); - for (const RecipeIngredient& material : def.schematic.materials) - { - lines << itemLine(material.item, material.amount); - } - lines << QObject::tr(" + installed modules"); - } - - lines << QObject::tr("Completion time: %1 s") - .arg(def.schematic.productionTimeSeconds); - lines << QObject::tr("Produces: 1 %1").arg(name); - - return lines.join('\n'); + return amounts; } } // namespace @@ -64,16 +49,24 @@ std::vector buildRecipeSelectionOptions( BuildingType type, Simulation& sim, const GameConfig& config) { std::vector options; - options.push_back({std::string(), QObject::tr("(None)"), QString()}); + options.push_back({std::string(), QObject::tr("(None)"), RecipeLineRow::Spec()}); if (type == BuildingType::Shipyard) { for (const ShipDef& def : config.ships.ships) { if (!sim.isSchematicUnlocked(def.id)) { continue; } + + // The base materials and base production time, excluding the modules the + // player has not placed yet (REQ-UI-SELECT-OPTIONS). No output item: a ship + // has no icon, so the caption is what the option produces. + RecipeLineRow::Spec line; + line.inputs = toAmounts(def.schematic.materials); + line.durationSeconds = def.schematic.productionTimeSeconds; + options.push_back({def.id, QString::fromStdString(toDisplayName(def.id)), - shipTooltip(def)}); + line}); } } else @@ -87,16 +80,15 @@ std::vector buildRecipeSelectionOptions( { continue; } - // Icon shown on the option button (REQ-UI-RECIPE-ICON): the recipe's - // configured icon item, else its first output item. - const std::string iconItemId = recipe.icon - ? *recipe.icon - : (recipe.outputs.empty() ? std::string() - : recipe.outputs.front().item); + + RecipeLineRow::Spec line; + line.inputs = toAmounts(recipe.inputs); + line.outputs = toAmounts(recipe.outputs); + line.durationSeconds = recipe.durationSeconds; + options.push_back({recipe.id, QString::fromStdString(toDisplayName(recipe.id)), - buildRecipeTooltip(recipe), - iconItemId}); + line}); } } @@ -105,51 +97,75 @@ std::vector buildRecipeSelectionOptions( namespace { - // On-screen size of a recipe option button's item icon (REQ-UI-RECIPE-ICON). - const QSize kOptionIconSize(32, 32); + // Height the option list is allowed to take, as the dialog's parent window's height + // less what the window margins and the dialog's own frame need. Fully unlocked, an + // Assembler offers more options than any window can hold, and the modal is never + // resized to fit (REQ-UI-PANEL-MODAL) -- so the list scrolls instead. + const int kListHeightReservePx = 80; + + // Fallback cap for a dialog built without a parent window to measure. + const int kFallbackListHeightPx = 600; } RecipeSelectionDialog::RecipeSelectionDialog( const std::vector& options, - const QString& title, ItemIconCache* itemIcons, QWidget* parent) + const QString& title, ItemIconCache* itemIcons, BuildingIconCache* buildingIcons, + QWidget* parent) : QDialog(parent) { setWindowTitle(title); setModal(true); QVBoxLayout* mainLayout = new QVBoxLayout(this); - QGridLayout* grid = new QGridLayout(); - mainLayout->addLayout(grid); - const int columnCount = 2; + // One vertical column of buttons, each stating what it makes (REQ-UI-SELECT-OPTIONS). + QScrollArea* scrollArea = new QScrollArea(this); + scrollArea->setWidgetResizable(true); + scrollArea->setFrameShape(QFrame::NoFrame); + scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents); + scrollArea->setMaximumHeight(parent != nullptr + ? qMax(kListHeightReservePx, parent->height() - kListHeightReservePx) + : kFallbackListHeightPx); + mainLayout->addWidget(scrollArea); + + QWidget* list = new QWidget(scrollArea); + QVBoxLayout* listLayout = new QVBoxLayout(list); + listLayout->setContentsMargins(0, 0, 0, 0); + listLayout->setSpacing(4); + for (int i = 0; i < static_cast(options.size()); ++i) { const RecipeSelectionOption& option = options[static_cast(i)]; - QPushButton* button = new QPushButton(this); - // Icon-only for a recipe option: the produced item's icon on its colored square, - // or the square alone when the item has no icon file (REQ-UI-RECIPE-ICON). The - // name stays reachable via the tooltip. Options carrying no item at all -- the - // "(None)" entry and the Shipyard's schematics -- keep their text caption, as - // does an item with neither square nor icon to show. - const QPixmap icon = option.iconItemId.empty() - ? QPixmap() - : itemIcons->getSquarePixmap(option.iconItemId, kOptionIconSize.width()); - if (!icon.isNull()) + QPushButton* button = new QPushButton(list); + if (option.line.isEmpty()) { - button->setIcon(QIcon(icon)); - button->setIconSize(kOptionIconSize); + // The "(None)" option makes nothing and is a caption alone + // (REQ-UI-SELECT-OPTIONS). + button->setText(option.caption); } else { - button->setText(option.caption); + // The name over the recipe it stands for. Both are transparent to the mouse + // so a click anywhere on the face still reaches the button beneath them. + QVBoxLayout* face = new QVBoxLayout(button); + face->setContentsMargins(8, 6, 8, 6); + face->setSpacing(2); + + QLabel* nameLabel = new QLabel(option.caption, button); + QFont nameFont = nameLabel->font(); + nameFont.setBold(true); + nameLabel->setFont(nameFont); + nameLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true); + face->addWidget(nameLabel); + + RecipeLineRow* line = new RecipeLineRow(itemIcons, buildingIcons, button); + line->setAttribute(Qt::WA_TransparentForMouseEvents, true); + face->addWidget(line); + line->setLine(option.line); } - if (!option.tooltip.isEmpty()) - { - button->setToolTip(option.tooltip); - } - button->setMinimumHeight(40); - grid->addWidget(button, i / columnCount, i % columnCount); + listLayout->addWidget(button); m_optionIds.push_back(option.id); const int index = i; @@ -158,6 +174,9 @@ RecipeSelectionDialog::RecipeSelectionDialog( onOptionClicked(index); }); } + listLayout->addStretch(1); + + scrollArea->setWidget(list); } std::optional RecipeSelectionDialog::getChosenId() const diff --git a/src/ui/RecipeSelectionDialog.h b/src/ui/RecipeSelectionDialog.h index 66b9243..897d3f6 100644 --- a/src/ui/RecipeSelectionDialog.h +++ b/src/ui/RecipeSelectionDialog.h @@ -8,11 +8,13 @@ #include #include "BuildingType.h" +#include "RecipeLineRow.h" struct GameConfig; +class BuildingIconCache; +class ItemIconCache; class Simulation; class QPushButton; -class ItemIconCache; // One selectable entry in the recipe/schematic selection dialog // (REQ-UI-SELECT-BUTTON). The "(None)" entry uses an empty id. @@ -20,23 +22,23 @@ struct RecipeSelectionOption { std::string id; QString caption; - QString tooltip; - // Id of the item whose icon is shown on the option button (REQ-UI-RECIPE-ICON); - // empty for the "(None)" option and for Shipyard schematics, which keep their - // caption. When set but no icon file exists, the button falls back to the caption. - std::string iconItemId; + // What the option makes, drawn beneath its caption on the button + // (REQ-UI-SELECT-OPTIONS). Empty for the "(None)" option, which is a caption alone. + // A ship schematic fills the inputs and the time and leaves the outputs empty: a + // ship is not an item and has no icon, so the caption is what it produces. + RecipeLineRow::Spec line; }; // Builds the lock-aware list of selectable options for a production building -// of the given type, with display captions and info tooltips -// (REQ-UI-SELECT-BUTTON, REQ-UI-SELECT-TOOLTIP). The first entry is always a +// of the given type, with display captions and recipe lines +// (REQ-UI-SELECT-BUTTON, REQ-UI-SELECT-OPTIONS). The first entry is always a // "(None)" option with an empty id. Shared by the selection dialog and the -// panel selection button so both render identical captions and tooltips, and +// panel selection button so both render identical captions, and // usable for construction sites which have no Building yet (REQ-BLD-SITE-CONFIG). std::vector buildRecipeSelectionOptions( BuildingType type, Simulation& sim, const GameConfig& config); -// Modal dialog showing a grid of option buttons (REQ-UI-SELECT-BUTTON). The +// Modal dialog listing the options in one vertical column (REQ-UI-SELECT-OPTIONS). The // game is paused by the caller while it is open. Clicking an option selects it // and closes the dialog; dismissing it (close/Esc) leaves no choice. class RecipeSelectionDialog : public QDialog @@ -44,12 +46,10 @@ class RecipeSelectionDialog : public QDialog Q_OBJECT public: - // itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); used to - // render recipe options icon-only (REQ-UI-RECIPE-ICON). Options whose item has - // no icon file fall back to their caption text. Not owned. + // The icon caches draw each option's recipe line (REQ-UI-ITEM-ICON). Not owned. RecipeSelectionDialog(const std::vector& options, const QString& title, ItemIconCache* itemIcons, - QWidget* parent = nullptr); + BuildingIconCache* buildingIcons, QWidget* parent = nullptr); std::optional getChosenId() const; diff --git a/src/ui/RecipeTooltip.cpp b/src/ui/RecipeTooltip.cpp deleted file mode 100644 index a5761b6..0000000 --- a/src/ui/RecipeTooltip.cpp +++ /dev/null @@ -1,52 +0,0 @@ -#include "RecipeTooltip.h" - -#include -#include - -#include "DisplayName.h" -#include "RecipesConfig.h" - -namespace -{ - -QString itemLine(const std::string& itemId, int amount) -{ - return QStringLiteral(" ") - + QString::fromStdString(toDisplayName(itemId)) - + QStringLiteral(" x ") + QString::number(amount); -} - -} // namespace - -QString buildRecipeTooltip(const RecipeDef& recipe) -{ - QStringList lines; - lines << QObject::tr("Recipe: %1") - .arg(QString::fromStdString(toDisplayName(recipe.id))); - - if (recipe.inputs.empty()) - { - lines << QObject::tr("Inputs: none"); - } - else - { - lines << QObject::tr("Inputs:"); - for (const RecipeIngredient& ingredient : recipe.inputs) - { - lines << itemLine(ingredient.item, ingredient.amount); - } - } - - lines << QObject::tr("Completion time: %1 s").arg(recipe.durationSeconds); - - if (!recipe.outputs.empty()) - { - lines << QObject::tr("Produces:"); - for (const RecipeOutput& output : recipe.outputs) - { - lines << itemLine(output.item, output.amount); - } - } - - return lines.join('\n'); -} diff --git a/src/ui/RecipeTooltip.h b/src/ui/RecipeTooltip.h deleted file mode 100644 index 5671637..0000000 --- a/src/ui/RecipeTooltip.h +++ /dev/null @@ -1,11 +0,0 @@ -#pragma once - -#include - -struct RecipeDef; - -// Builds the recipe info tooltip text (REQ-UI-SELECT-TOOLTIP): the recipe name, -// each input item name and quantity, the completion time, and the produced -// output item name and quantity. Shared by the recipe-selection dialog and the -// schematic choice dialog's "Unlocks recipes:" list so both render identically. -QString buildRecipeTooltip(const RecipeDef& recipe); diff --git a/src/ui/SchematicChoiceDialog.cpp b/src/ui/SchematicChoiceDialog.cpp index 8d831bb..c1da16a 100644 --- a/src/ui/SchematicChoiceDialog.cpp +++ b/src/ui/SchematicChoiceDialog.cpp @@ -6,12 +6,35 @@ #include #include "DisplayName.h" -#include "RecipeTooltip.h" +#include "RecipeLineRow.h" #include "RecipesConfig.h" namespace { +std::vector toAmounts( + const std::vector& ingredients) +{ + std::vector amounts; + amounts.reserve(ingredients.size()); + for (const RecipeIngredient& ingredient : ingredients) + { + amounts.push_back(RecipeLineRow::Amount{ ingredient.item, ingredient.amount }); + } + return amounts; +} + +std::vector toAmounts(const std::vector& outputs) +{ + std::vector amounts; + amounts.reserve(outputs.size()); + for (const RecipeOutput& output : outputs) + { + amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount }); + } + return amounts; +} + QString grantKindLabel(SchematicType type) { switch (type) @@ -28,7 +51,8 @@ QString grantKindLabel(SchematicType type) SchematicChoiceDialog::SchematicChoiceDialog( const std::vector& options, - const RecipesConfig& recipes, + const RecipesConfig& recipes, ItemIconCache* itemIcons, + BuildingIconCache* buildingIcons, QWidget* parent) : QDialog(parent) , m_chosenIndex(0) @@ -108,14 +132,23 @@ SchematicChoiceDialog::SchematicChoiceDialog( { for (const std::string& recipeId : option.newlyUnlockedRecipeIds) { - QLabel* recipeLabel = new QLabel( - QString::fromStdString(toDisplayName(recipeId)), card); - recipeLabel->setAlignment(Qt::AlignCenter); - if (const RecipeDef* def = recipes.findRecipeDef(recipeId)) - { - recipeLabel->setToolTip(buildRecipeTooltip(*def)); - } - cardLayout->addWidget(recipeLabel); + const RecipeDef* def = recipes.findRecipeDef(recipeId); + if (def == nullptr) { continue; } + + // The recipe drawn as it is everywhere else, led by the building + // that runs it and its name, so the line says everything there is + // to say about it and carries no tooltip (REQ-DEF-SCHEMATIC-DROP). + RecipeLineRow::Spec spec; + spec.building = def->building; + spec.name = QString::fromStdString(toDisplayName(def->id)); + spec.inputs = toAmounts(def->inputs); + spec.outputs = toAmounts(def->outputs); + spec.durationSeconds = def->durationSeconds; + + RecipeLineRow* line = + new RecipeLineRow(itemIcons, buildingIcons, card); + cardLayout->addWidget(line); + line->setLine(spec); } } } diff --git a/src/ui/SchematicChoiceDialog.h b/src/ui/SchematicChoiceDialog.h index 36a7446..745f97a 100644 --- a/src/ui/SchematicChoiceDialog.h +++ b/src/ui/SchematicChoiceDialog.h @@ -7,14 +7,19 @@ #include "SchematicChoiceOption.h" struct RecipesConfig; +class BuildingIconCache; +class ItemIconCache; class SchematicChoiceDialog : public QDialog { Q_OBJECT public: + // The icon caches draw the recipe lines of the "Unlocks recipes" list + // (REQ-DEF-SCHEMATIC-DROP, REQ-UI-ITEM-ICON, REQ-UI-BUILD-ICON). Not owned. SchematicChoiceDialog(const std::vector& options, - const RecipesConfig& recipes, + const RecipesConfig& recipes, ItemIconCache* itemIcons, + BuildingIconCache* buildingIcons, QWidget* parent = nullptr); int getChosenIndex() const; diff --git a/src/ui/ShipLayoutDialog.cpp b/src/ui/ShipLayoutDialog.cpp index 5551ad3..9dc1ac8 100644 --- a/src/ui/ShipLayoutDialog.cpp +++ b/src/ui/ShipLayoutDialog.cpp @@ -5,11 +5,15 @@ #include #include "DisplayName.h" +#include "ProductionRules.h" +#include "RecipeLineRow.h" +#include "SectionBox.h" #include #include #include #include +#include #include #include #include @@ -22,6 +26,18 @@ namespace { +std::vector toAmounts( + const std::vector& ingredients) +{ + std::vector amounts; + amounts.reserve(ingredients.size()); + for (const RecipeIngredient& ingredient : ingredients) + { + amounts.push_back(RecipeLineRow::Amount{ ingredient.item, ingredient.amount }); + } + return amounts; +} + const int kCellSize = 32; // Empty ship tiles pulse between this grey and white at 1 Hz while a module is @@ -401,9 +417,11 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config, std::vector& allBlueprints, std::set unlockedModuleIds, bool debugDraw, + ItemIconCache* itemIcons, QWidget* parent) : QDialog(parent) , m_config(config) + , m_itemIcons(itemIcons) , m_shipId(shipId) , m_unlockedModuleIds(std::move(unlockedModuleIds)) , m_rows(0) @@ -463,10 +481,22 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config, // Middle: three-column area (stats | module buttons | blueprints). QHBoxLayout* columnsLayout = new QHBoxLayout(); - // Left column: ship stats panel. + // Left column: ship stats panel, and beneath it what the layout being configured + // would cost to build (REQ-MOD-UI-DIALOG). The cost sits outside the stats panel + // because that widget is shared with the live ship card and the balancing tool, + // neither of which costs anything -- and the balancing tool is deliberately built + // without the item icons this line draws with. + QVBoxLayout* leftLayout = new QVBoxLayout(); m_statsPanel = new ShipStatsPanel(config, this); m_statsPanel->setDebugDrawEnabled(m_debugDraw); - columnsLayout->addWidget(m_statsPanel); + leftLayout->addWidget(m_statsPanel); + + m_buildCostSection = new SectionBox(tr("Build cost"), this); + m_buildCostLine = new RecipeLineRow(m_itemIcons, nullptr, m_buildCostSection); + m_buildCostSection->getContentLayout()->addWidget(m_buildCostLine); + leftLayout->addWidget(m_buildCostSection); + leftLayout->addStretch(1); + columnsLayout->addLayout(leftLayout); // Center column: module selection buttons. QVBoxLayout* centerLayout = new QVBoxLayout(); @@ -487,11 +517,34 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config, m_moduleButtons.push_back(nullptr); continue; } - const QString label = QString::fromStdString(toDisplayName(def.id)) - + "\n" + QString::fromStdString(def.glyph); - QPushButton* btn = new QPushButton(label, this); + // The module's name and glyph over what it costs -- its materials and the + // production time it adds (REQ-MOD-UI-DIALOG). Both children are transparent to + // the mouse so a click anywhere on the face still reaches the button. + QPushButton* btn = new QPushButton(this); btn->setCheckable(true); - btn->setFixedHeight(48); + + QVBoxLayout* face = new QVBoxLayout(btn); + face->setContentsMargins(8, 4, 8, 4); + face->setSpacing(1); + + QLabel* nameLabel = new QLabel( + QString::fromStdString(toDisplayName(def.id)) + + " " + QString::fromStdString(def.glyph), btn); + nameLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true); + face->addWidget(nameLabel); + + RecipeLineRow::Spec cost; + cost.inputs = toAmounts(def.materials); + cost.durationSeconds = def.productionTimeSeconds; + cost.durationIsAddition = true; + + RecipeLineRow* costLine = new RecipeLineRow(m_itemIcons, nullptr, btn); + costLine->setAttribute(Qt::WA_TransparentForMouseEvents, true); + face->addWidget(costLine); + costLine->setLine(cost); + + // The config tooltip stays: it says what the module does, which the cost line + // does not (REQ-MOD-UI-MODULE-TOOLTIP). if (def.tooltip) { btn->setToolTip(QString::fromStdString(*def.tooltip)); @@ -731,6 +784,29 @@ void ShipLayoutDialog::updateGridWidget() void ShipLayoutDialog::updateStats() { m_statsPanel->refresh(m_shipId, m_placedModules); + + // What the layout as it currently stands would cost to build, so the price follows + // every placement and removal (REQ-MOD-UI-DIALOG). The sums come from the + // simulation's own rules rather than being added up a second time here + // (REQ-MOD-MATERIALS, REQ-MOD-PRODUCTION-TIME). + ShipLayoutConfig layout; + layout.placedModules = m_placedModules; + const std::map materials = + computeShipyardRequiredMaterials(*m_config, m_shipId, layout); + + RecipeLineRow::Spec cost; + cost.inputs.reserve(materials.size()); + for (const std::pair& entry : materials) + { + cost.inputs.push_back(RecipeLineRow::Amount{ entry.first, entry.second }); + } + cost.durationSeconds = + computeShipyardProductionTimeSeconds(*m_config, m_shipId, layout); + + m_buildCostLine->setLine(cost); + // A schematic costing no materials at all has no line to draw, and the section then + // stays out of the way. + m_buildCostSection->setVisible(!cost.inputs.empty()); } bool ShipLayoutDialog::canPlaceModule(const ModuleDef& def, QPoint position, diff --git a/src/ui/ShipLayoutDialog.h b/src/ui/ShipLayoutDialog.h index cdb3579..d1029a1 100644 --- a/src/ui/ShipLayoutDialog.h +++ b/src/ui/ShipLayoutDialog.h @@ -14,7 +14,10 @@ #include "ShipLayout.h" #include "ShipLayoutBlueprint.h" +class ItemIconCache; class QPushButton; +class RecipeLineRow; +class SectionBox; class ShipStatsPanel; class ShipLayoutDialog : public QDialog @@ -22,12 +25,15 @@ class ShipLayoutDialog : public QDialog Q_OBJECT public: + // itemIcons draws what each module costs and what the layout costs in total + // (REQ-MOD-UI-DIALOG, REQ-MOD-UI-STATS-PANEL). Not owned. ShipLayoutDialog(const GameConfig* config, const std::string& shipId, const ShipLayoutConfig& currentLayout, std::vector& allBlueprints, std::set unlockedModuleIds, bool debugDraw, + ItemIconCache* itemIcons, QWidget* parent = nullptr); std::optional getResult() const; @@ -60,6 +66,7 @@ private: void loadLayoutBlueprint(const std::vector& modules); const GameConfig* m_config; + ItemIconCache* m_itemIcons; std::string m_shipId; std::set m_unlockedModuleIds; std::vector m_shipLayout; @@ -81,6 +88,8 @@ private: QPushButton* m_removeButton; QWidget* m_gridWidget; ShipStatsPanel* m_statsPanel; + SectionBox* m_buildCostSection; + RecipeLineRow* m_buildCostLine; bool m_debugDraw; std::optional m_result; diff --git a/src/ui/selection/BufferedBuildingContent.cpp b/src/ui/selection/BufferedBuildingContent.cpp index b53a6e3..f514e59 100644 --- a/src/ui/selection/BufferedBuildingContent.cpp +++ b/src/ui/selection/BufferedBuildingContent.cpp @@ -10,7 +10,7 @@ #include "DisplayName.h" #include "FactoryQueries.h" #include "ProductionSection.h" -#include "RecipeSummaryRow.h" +#include "RecipeLineRow.h" #include "SectionBox.h" #include "SelectionNames.h" #include "Simulation.h" @@ -18,13 +18,13 @@ namespace { -std::vector toAmounts(const std::map& map) +std::vector toAmounts(const std::map& map) { - std::vector amounts; + std::vector amounts; amounts.reserve(map.size()); for (const std::pair& entry : map) { - amounts.push_back(RecipeSummaryRow::Amount{ entry.first, entry.second }); + amounts.push_back(RecipeLineRow::Amount{ entry.first, entry.second }); } return amounts; } @@ -66,17 +66,17 @@ BufferedBuildingContent::BufferedBuildingContent(const SelectionContext& context { // The summary is configuration -- what the building will do -- so it sits with the // selection control and is shown for a construction site too (REQ-UI-RECIPE-SUMMARY). - m_recipeSummary = new RecipeSummaryRow(context.itemIcons, this); + m_recipeSummary = new RecipeLineRow(context.itemIcons, context.buildingIcons, this); getConfigurationLayout()->addWidget(m_recipeSummary); m_inputSection = new SectionBox(tr("Input buffers"), this); - m_inputChips = new ItemChipRow(context.itemIcons, m_inputSection); + m_inputChips = new ItemChipRow(context, m_inputSection); m_inputSection->getContentLayout()->addWidget(m_inputChips); m_production = new ProductionSection(this); m_outputSection = new SectionBox(tr("Output buffer"), this); - m_outputChips = new ItemChipRow(context.itemIcons, m_outputSection); + m_outputChips = new ItemChipRow(context, m_outputSection); m_outputSection->getContentLayout()->addWidget(m_outputChips); // In the direction the materials flow: what goes in, what is being made of it, what @@ -98,10 +98,14 @@ void BufferedBuildingContent::refreshConfiguration() setBuildingIdentity(target.type, getBuildingTypeName(target.type)); + // The card already names the building in its header, so the summary states the cycle + // alone -- no building chip, no recipe name (REQ-UI-RECIPE-SUMMARY). const CycleInfo cycle = getCycleInfo(target); - m_recipeSummary->setSummary(toAmounts(cycle.perCycleInputs), - toAmounts(cycle.perCycleOutputs), - cycle.durationSeconds); + RecipeLineRow::Spec summary; + summary.inputs = toAmounts(cycle.perCycleInputs); + summary.outputs = toAmounts(cycle.perCycleOutputs); + summary.durationSeconds = cycle.durationSeconds; + m_recipeSummary->setLine(summary); refreshControls(target); } diff --git a/src/ui/selection/BufferedBuildingContent.h b/src/ui/selection/BufferedBuildingContent.h index 9fded6a..46b7d67 100644 --- a/src/ui/selection/BufferedBuildingContent.h +++ b/src/ui/selection/BufferedBuildingContent.h @@ -11,7 +11,7 @@ struct Building; struct BuildingTarget; class ProductionSection; -class RecipeSummaryRow; +class RecipeLineRow; class SectionBox; // Shared body of the four cards that show one building with buffers -- the Miner and @@ -74,7 +74,7 @@ private: BuildingId m_id; - RecipeSummaryRow* m_recipeSummary; + RecipeLineRow* m_recipeSummary; // Input buffers, production progress, output buffer -- in that order, so the card // reads the way the materials flow (REQ-UI-SINGLE-SELECTION). diff --git a/src/ui/selection/CMakeLists.txt b/src/ui/selection/CMakeLists.txt index fdb1129..75ab62b 100644 --- a/src/ui/selection/CMakeLists.txt +++ b/src/ui/selection/CMakeLists.txt @@ -14,7 +14,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/EmptyNote.h ${CMAKE_CURRENT_SOURCE_DIR}/ItemChip.h ${CMAKE_CURRENT_SOURCE_DIR}/ItemChipRow.h - ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSummaryRow.h + ${CMAKE_CURRENT_SOURCE_DIR}/ItemTooltip.h ${CMAKE_CURRENT_SOURCE_DIR}/ProductionSection.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionControl.h ${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.h @@ -49,7 +49,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/EmptyNote.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ItemChip.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ItemChipRow.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSummaryRow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ItemTooltip.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ProductionSection.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionControl.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.cpp diff --git a/src/ui/selection/HqContent.cpp b/src/ui/selection/HqContent.cpp index 32190c3..3e0a737 100644 --- a/src/ui/selection/HqContent.cpp +++ b/src/ui/selection/HqContent.cpp @@ -23,7 +23,7 @@ HqContent::HqContent(const SelectionContext& context, const SelectionRequest& re m_hpBar = new BarRow(tr("HP"), this); m_stockSection = new SectionBox(tr("Building blocks"), this); - m_stockChips = new ItemChipRow(context.itemIcons, m_stockSection); + m_stockChips = new ItemChipRow(context, m_stockSection); m_stockSection->getContentLayout()->addWidget(m_stockChips); // HP first, as on every card that has it (REQ-UI-SELECTION-CARD, REQ-UI-HQ-PANEL). diff --git a/src/ui/selection/ItemChip.cpp b/src/ui/selection/ItemChip.cpp index 49f128b..11d3ebd 100644 --- a/src/ui/selection/ItemChip.cpp +++ b/src/ui/selection/ItemChip.cpp @@ -1,11 +1,15 @@ #include "ItemChip.h" +#include #include #include #include #include +#include #include +#include "ItemTooltip.h" + namespace { @@ -13,11 +17,18 @@ namespace const int kCountSizeRisePt = 2; const int kSubLineSizeDropPt = 1; +// How long the cursor rests on a chip before its tooltip appears, in milliseconds. +// Matches the feel of an ordinary tooltip, which this one replaces (REQ-UI-ITEM-TOOLTIP). +const int kHoverDelayMs = 500; + } // namespace -ItemChip::ItemChip(const QPixmap& icon, QWidget* parent) +ItemChip::ItemChip(const SelectionContext& context, const std::string& itemId, + const QPixmap& icon, QWidget* parent) : QWidget(parent) + , m_context(context) + , m_itemId(itemId) { // Its own boxed chrome, drawn with palette colors like the rest of the panel's // furniture rather than from visuals.toml, which is for world rendering. @@ -58,6 +69,11 @@ ItemChip::ItemChip(const QPixmap& icon, QWidget* parent) layout->addWidget(m_iconLabel); layout->addWidget(text); + + m_hoverTimer = new QTimer(this); + m_hoverTimer->setSingleShot(true); + m_hoverTimer->setInterval(kHoverDelayMs); + connect(m_hoverTimer, &QTimer::timeout, this, &ItemChip::showTooltip); } void ItemChip::setCount(const QString& count) @@ -70,3 +86,28 @@ void ItemChip::setSubLine(const QString& subLine) m_subLineLabel->setText(subLine); m_subLineLabel->setVisible(!subLine.isEmpty()); } + +void ItemChip::enterEvent(QEvent* event) +{ + m_hoverTimer->start(); + QWidget::enterEvent(event); +} + +void ItemChip::leaveEvent(QEvent* event) +{ + m_hoverTimer->stop(); + if (m_tooltip != nullptr) + { + m_tooltip->hide(); + } + QWidget::leaveEvent(event); +} + +void ItemChip::showTooltip() +{ + if (m_tooltip == nullptr) + { + m_tooltip = new ItemTooltip(m_context, m_itemId, this); + } + m_tooltip->showAt(QCursor::pos()); +} diff --git a/src/ui/selection/ItemChip.h b/src/ui/selection/ItemChip.h index 1bba711..5934107 100644 --- a/src/ui/selection/ItemChip.h +++ b/src/ui/selection/ItemChip.h @@ -1,28 +1,51 @@ #pragma once +#include + #include #include #include +#include "SelectionContext.h" + +class ItemTooltip; +class QEvent; class QLabel; +class QTimer; // One buffered item: its icon, its count in a larger type, and a sub-line beneath the // count (REQ-UI-SINGLE-SELECTION). Boxed so a row of them reads as separate quantities // rather than as a run of text. +// +// Hovering it explains where its item comes from (REQ-UI-ITEM-TOOLTIP). class ItemChip : public QWidget { Q_OBJECT public: // An empty icon leaves the icon off and the chip laid out around the text alone. - ItemChip(const QPixmap& icon, QWidget* parent = nullptr); + ItemChip(const SelectionContext& context, const std::string& itemId, + const QPixmap& icon, QWidget* parent = nullptr); void setCount(const QString& count); // Left empty for an item with nothing to say beneath its count. void setSubLine(const QString& subLine); +protected: + void enterEvent(QEvent* event) override; + void leaveEvent(QEvent* event) override; + private: - QLabel* m_iconLabel; - QLabel* m_countLabel; - QLabel* m_subLineLabel; + void showTooltip(); + + SelectionContext m_context; + std::string m_itemId; + QLabel* m_iconLabel; + QLabel* m_countLabel; + QLabel* m_subLineLabel; + // Held back so a cursor crossing the chip on its way elsewhere does not flash the + // tooltip, the way an ordinary tooltip is. + QTimer* m_hoverTimer; + // Built on the first hover, because most chips are never hovered at all. + ItemTooltip* m_tooltip = nullptr; }; diff --git a/src/ui/selection/ItemChipRow.cpp b/src/ui/selection/ItemChipRow.cpp index 471e039..cdd153f 100644 --- a/src/ui/selection/ItemChipRow.cpp +++ b/src/ui/selection/ItemChipRow.cpp @@ -19,9 +19,9 @@ const int kChipsPerRow = 2; } // namespace -ItemChipRow::ItemChipRow(ItemIconCache* itemIcons, QWidget* parent) +ItemChipRow::ItemChipRow(const SelectionContext& context, QWidget* parent) : QWidget(parent) - , m_itemIcons(itemIcons) + , m_context(context) { m_layout = new QGridLayout(this); m_layout->setContentsMargins(0, 0, 0, 0); @@ -68,9 +68,10 @@ void ItemChipRow::rebuildChips(const std::vector& entries) // The item's icon on its colored square (REQ-UI-ITEM-ICON). A missing icon file // is not an error: the chip then carries the square alone, and only an item with // no square either leaves the chip laid out around its count alone. - const QPixmap icon = m_itemIcons->getSquarePixmap(itemId, kChipIconSizePx); + const QPixmap icon = + m_context.itemIcons->getSquarePixmap(itemId, kChipIconSizePx); - ItemChip* chip = new ItemChip(icon, this); + ItemChip* chip = new ItemChip(m_context, itemId, icon, this); m_layout->addWidget(chip, static_cast(index) / kChipsPerRow, static_cast(index) % kChipsPerRow); // Shown right away, because the panel measures itself as soon as this returns. A diff --git a/src/ui/selection/ItemChipRow.h b/src/ui/selection/ItemChipRow.h index e1026ac..d2fda69 100644 --- a/src/ui/selection/ItemChipRow.h +++ b/src/ui/selection/ItemChipRow.h @@ -6,8 +6,9 @@ #include #include +#include "SelectionContext.h" + class ItemChip; -class ItemIconCache; class QGridLayout; // The buffered items of one building, each as a chip carrying the item's icon, its @@ -28,16 +29,18 @@ public: QString subLine; }; - // itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); an item with - // no icon file simply shows no icon, which is not an error. Not owned. - explicit ItemChipRow(ItemIconCache* itemIcons, QWidget* parent = nullptr); + // The context supplies the icon cache each chip draws its item with + // (REQ-UI-ITEM-ICON) -- an item with no icon file simply shows no icon, which is not + // an error -- and what a chip's tooltip reads to explain its item + // (REQ-UI-ITEM-TOOLTIP). + explicit ItemChipRow(const SelectionContext& context, QWidget* parent = nullptr); void setEntries(const std::vector& entries); private: void rebuildChips(const std::vector& entries); - ItemIconCache* m_itemIcons; + SelectionContext m_context; QGridLayout* m_layout; std::vector m_itemIds; // what the chips currently stand for std::vector m_chips; diff --git a/src/ui/selection/ItemTooltip.cpp b/src/ui/selection/ItemTooltip.cpp new file mode 100644 index 0000000..41ad63d --- /dev/null +++ b/src/ui/selection/ItemTooltip.cpp @@ -0,0 +1,163 @@ +#include "ItemTooltip.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "DisplayName.h" +#include "GameConfig.h" +#include "ItemProducers.h" +#include "RecipeLineRow.h" +#include "RecipesConfig.h" +#include "Simulation.h" + +namespace +{ + +// Gap between the cursor and the tooltip's corner, in device-independent pixels. Wide +// enough that the cursor never covers the heading. +const int kCursorOffsetPx = 16; + +std::vector toAmounts( + const std::vector& ingredients) +{ + std::vector amounts; + amounts.reserve(ingredients.size()); + for (const RecipeIngredient& ingredient : ingredients) + { + amounts.push_back(RecipeLineRow::Amount{ ingredient.item, ingredient.amount }); + } + return amounts; +} + +std::vector toAmounts(const std::vector& outputs) +{ + std::vector amounts; + amounts.reserve(outputs.size()); + for (const RecipeOutput& output : outputs) + { + amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount }); + } + return amounts; +} + +// The screen the cursor is on, falling back to the primary screen when the position is +// on none of them (a cursor between two screens of different heights). +QRect availableScreenRect(const QPoint& globalPos) +{ + const QScreen* screen = QGuiApplication::screenAt(globalPos); + if (screen == nullptr) + { + screen = QGuiApplication::primaryScreen(); + } + return (screen != nullptr) ? screen->availableGeometry() : QRect(); +} + +} // namespace + + +ItemTooltip::ItemTooltip(const SelectionContext& context, const std::string& itemId, + QWidget* parent) + : QFrame(parent, Qt::ToolTip) + , m_context(context) + , m_itemId(itemId) +{ + // The palette's tooltip colors behind a themed border, so it reads as a tooltip + // rather than as a stray window. + setFrameShape(QFrame::StyledPanel); + setAutoFillBackground(true); + QPalette tooltipPalette = palette(); + tooltipPalette.setColor(QPalette::Window, + tooltipPalette.color(QPalette::ToolTipBase)); + tooltipPalette.setColor(QPalette::WindowText, + tooltipPalette.color(QPalette::ToolTipText)); + setPalette(tooltipPalette); + + m_layout = new QVBoxLayout(this); + m_layout->setContentsMargins(6, 4, 6, 4); + m_layout->setSpacing(2); +} + +void ItemTooltip::showAt(const QPoint& globalPos) +{ + rebuild(); + adjustSize(); + + // Kept on the screen the cursor is on: a tooltip for a chip near the right or + // bottom edge would otherwise be cut off. + const QRect screen = availableScreenRect(globalPos); + QPoint topLeft(globalPos.x() + kCursorOffsetPx, globalPos.y() + kCursorOffsetPx); + if (screen.isValid()) + { + topLeft.setX(qMax(screen.left(), + qMin(topLeft.x(), screen.right() - width() + 1))); + topLeft.setY(qMax(screen.top(), + qMin(topLeft.y(), screen.bottom() - height() + 1))); + } + + move(topLeft); + show(); +} + +void ItemTooltip::rebuild() +{ + while (QLayoutItem* item = m_layout->takeAt(0)) + { + if (item->widget()) + { + item->widget()->deleteLater(); + } + delete item; + } + + // The heading names the item. For an input chip this is the only place it is named + // at all, since such a chip carries a count and no name (REQ-UI-SINGLE-SELECTION). + QLabel* heading = + new QLabel(QString::fromStdString(toDisplayName(m_itemId)), this); + QFont headingFont = heading->font(); + headingFont.setBold(true); + heading->setFont(headingFont); + m_layout->addWidget(heading); + heading->show(); + + const ItemProduction production = + findItemProduction(m_itemId, *m_context.sim, *m_context.config); + + // Scrap is salvaged from debris rather than crafted (REQ-RES-DEBRIS-DROP), so it + // says where it comes from in place of the caption and lists nothing. + QLabel* caption = new QLabel(production.origin == ItemOrigin::Salvaged + ? tr("Salvaged from debris") + : tr("Produced by"), this); + m_layout->addWidget(caption); + caption->show(); + + if (production.origin == ItemOrigin::Undiscovered) + { + // Made somehow, but by nothing the player has unlocked -- named without being + // shown a path they cannot take yet (REQ-UI-ITEM-TOOLTIP). + QLabel* undiscovered = new QLabel(tr("Undiscovered"), this); + m_layout->addWidget(undiscovered); + undiscovered->show(); + return; + } + + for (const RecipeDef* recipe : production.recipes) + { + RecipeLineRow::Spec spec; + spec.building = recipe->building; + spec.name = QString::fromStdString(toDisplayName(recipe->id)); + spec.inputs = toAmounts(recipe->inputs); + spec.outputs = toAmounts(recipe->outputs); + spec.durationSeconds = recipe->durationSeconds; + + RecipeLineRow* line = + new RecipeLineRow(m_context.itemIcons, m_context.buildingIcons, this); + m_layout->addWidget(line); + line->setLine(spec); + } +} diff --git a/src/ui/selection/ItemTooltip.h b/src/ui/selection/ItemTooltip.h new file mode 100644 index 0000000..4a077f0 --- /dev/null +++ b/src/ui/selection/ItemTooltip.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include + +#include "SelectionContext.h" + +class QVBoxLayout; + +// The tooltip an item chip shows on hover: the item's name, and every way the player +// can currently produce it, each drawn as a recipe line (REQ-UI-ITEM-TOOLTIP). +// +// A window of its own rather than a Qt tooltip, because Qt's are text and this one is +// drawn -- item squares, building chips and all. It is a Qt::ToolTip window, so it +// floats above everything and takes no input. +class ItemTooltip : public QFrame +{ + Q_OBJECT + +public: + ItemTooltip(const SelectionContext& context, const std::string& itemId, + QWidget* parent = nullptr); + + // Rebuilds the content and shows the tooltip near the given screen position, kept + // inside the screen. Rebuilt on every show because what produces an item changes as + // the player unlocks recipes (REQ-LOCK-UI-RECIPE). + void showAt(const QPoint& globalPos); + +private: + void rebuild(); + + SelectionContext m_context; + std::string m_itemId; + QVBoxLayout* m_layout; +}; diff --git a/src/ui/selection/RecipeSelectionControl.cpp b/src/ui/selection/RecipeSelectionControl.cpp index df43d62..6a97a8d 100644 --- a/src/ui/selection/RecipeSelectionControl.cpp +++ b/src/ui/selection/RecipeSelectionControl.cpp @@ -45,13 +45,14 @@ void RecipeSelectionControl::setRecipeId(const std::string& recipeId) if (option.id == recipeId && !option.id.empty()) { m_button->setText(option.caption); - m_button->setToolTip(option.tooltip); return; } } + // No tooltip of its own: what the building has selected is drawn beneath the button + // as the recipe summary, and where its items come from is reachable by hovering the + // card's item chips (REQ-UI-SELECT-BUTTON, REQ-UI-ITEM-TOOLTIP). m_button->setText(m_type == BuildingType::Shipyard ? tr("Select schematic") : tr("Select recipe")); - m_button->setToolTip(QString()); } diff --git a/src/ui/selection/RecipeSummaryRow.cpp b/src/ui/selection/RecipeSummaryRow.cpp deleted file mode 100644 index b308097..0000000 --- a/src/ui/selection/RecipeSummaryRow.cpp +++ /dev/null @@ -1,123 +0,0 @@ -#include "RecipeSummaryRow.h" - -#include -#include -#include -#include - -#include "ItemIconCache.h" - -namespace -{ - -// Size the items' colored squares are drawn at on the summary line, in device-independent -// pixels. Larger than the artwork they carry, which the square insets (REQ-UI-ITEM-ICON). -const int kSummaryIconSizePx = 18; - -// Adds a freshly built label to the summary and shows it. -// -// The show is what makes it count: a widget created under an already-visible parent -// starts hidden, and a layout treats a hidden item as empty, so an unshown label would -// add nothing to the size hint the panel measures itself against as soon as this -// returns. -void addAndShow(QHBoxLayout* layout, QLabel* label) -{ - layout->addWidget(label); - label->show(); -} - -} // namespace - - -RecipeSummaryRow::RecipeSummaryRow(ItemIconCache* itemIcons, QWidget* parent) - : QWidget(parent) - , m_itemIcons(itemIcons) -{ - m_layout = new QHBoxLayout(this); - m_layout->setContentsMargins(0, 0, 0, 0); - m_layout->setSpacing(4); - - hide(); -} - -void RecipeSummaryRow::setSummary(const std::vector& inputs, - const std::vector& outputs, - double durationSeconds) -{ - if (outputs.empty() && inputs.empty()) - { - // Forgotten as well as hidden, so re-selecting the same recipe later is seen as - // a change and shows the row again. - m_inputs.clear(); - m_outputs.clear(); - m_durationSeconds = -1.0; - hide(); - return; - } - if (inputs == m_inputs && outputs == m_outputs - && durationSeconds == m_durationSeconds) - { - return; - } - - m_inputs = inputs; - m_outputs = outputs; - m_durationSeconds = durationSeconds; - rebuild(inputs, outputs, durationSeconds); - show(); -} - -void RecipeSummaryRow::rebuild(const std::vector& inputs, - const std::vector& outputs, - double durationSeconds) -{ - while (QLayoutItem* item = m_layout->takeAt(0)) - { - if (item->widget()) - { - item->widget()->deleteLater(); - } - delete item; - } - - addAmounts(inputs); - if (!inputs.empty()) - { - const QChar rightArrow(0x2192); // U+2192 RIGHTWARDS ARROW - addAndShow(m_layout, new QLabel(QString(rightArrow), this)); - } - addAmounts(outputs); - - if (durationSeconds > 0.0) - { - const QChar middleDot(0x00B7); // U+00B7 MIDDLE DOT - addAndShow(m_layout, new QLabel( - QStringLiteral("%1 %2").arg(middleDot).arg( - tr("%1 s").arg(durationSeconds, 0, 'f', 1)), this)); - } - m_layout->addStretch(1); -} - -void RecipeSummaryRow::addAmounts(const std::vector& amounts) -{ - for (const Amount& entry : amounts) - { - // The item's icon on its colored square (REQ-UI-ITEM-ICON). A missing icon file - // is not an error: the square stands alone then, and only an item with no square - // either falls back to its id in text. - const QPixmap icon = - m_itemIcons->getSquarePixmap(entry.itemId, kSummaryIconSizePx); - if (!icon.isNull()) - { - QLabel* iconLabel = new QLabel(this); - iconLabel->setPixmap(icon); - addAndShow(m_layout, iconLabel); - } - else - { - addAndShow(m_layout, - new QLabel(QString::fromStdString(entry.itemId), this)); - } - addAndShow(m_layout, new QLabel(QString::number(entry.amount), this)); - } -} diff --git a/src/ui/selection/RecipeSummaryRow.h b/src/ui/selection/RecipeSummaryRow.h deleted file mode 100644 index 9d33105..0000000 --- a/src/ui/selection/RecipeSummaryRow.h +++ /dev/null @@ -1,52 +0,0 @@ -#pragma once - -#include -#include - -#include - -class ItemIconCache; -class QHBoxLayout; - -// What one production cycle does, on a line: each input item with its per-cycle amount, -// an arrow, each output with its amount, and the cycle time -// (REQ-UI-RECIPE-SUMMARY). It restates the selected recipe without the player having to -// open the selection dialog, and it is the panel's only display of the cycle time. -class RecipeSummaryRow : public QWidget -{ - Q_OBJECT - -public: - struct Amount - { - std::string itemId; - int amount = 0; - - bool operator==(const Amount& other) const - { - return itemId == other.itemId && amount == other.amount; - } - }; - - // itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); an item with - // no icon file falls back to its id. Not owned. - explicit RecipeSummaryRow(ItemIconCache* itemIcons, QWidget* parent = nullptr); - - // Hides the row when there is nothing selected to produce, which is how a building - // with no recipe shows no summary at all. - void setSummary(const std::vector& inputs, - const std::vector& outputs, double durationSeconds); - -private: - void rebuild(const std::vector& inputs, const std::vector& outputs, - double durationSeconds); - void addAmounts(const std::vector& amounts); - - ItemIconCache* m_itemIcons; - QHBoxLayout* m_layout; - // What the row currently shows, so a refresh at tick rate rebuilds it only when the - // recipe actually changed. - std::vector m_inputs; - std::vector m_outputs; - double m_durationSeconds = -1.0; -}; diff --git a/src/ui/selection/ShipyardContent.cpp b/src/ui/selection/ShipyardContent.cpp index de399b4..289ea20 100644 --- a/src/ui/selection/ShipyardContent.cpp +++ b/src/ui/selection/ShipyardContent.cpp @@ -83,20 +83,11 @@ BufferedBuildingContent::CycleInfo ShipyardContent::getCycleInfo( *getContext().config, target.recipeId, target.shipLayout); // A shipyard's output is the ship itself, which never lands in an item buffer -- so - // the summary shows one ship rather than an item id. - info.durationSeconds = shipDef->schematic.productionTimeSeconds; - if (target.shipLayout.has_value()) - { - for (const PlacedModule& placed : target.shipLayout->placedModules) - { - const ModuleDef* moduleDef = - getContext().config->modules.findModuleDef(placed.moduleId); - if (moduleDef) - { - info.durationSeconds += moduleDef->productionTimeSeconds; - } - } - } + // the summary shows no output item at all. The time is the schematic's plus its + // modules', from the same place the layout dialog's build cost reads it + // (REQ-MOD-PRODUCTION-TIME). + info.durationSeconds = computeShipyardProductionTimeSeconds( + *getContext().config, target.recipeId, target.shipLayout); info.runsProduction = true; return info; }