Skip to content

Commit 7358e0a

Browse files
authored
Add a units bar, showing units inside the territory after its name in the bottom bar. (#10404)
Add a units bar to the main UI, showing units inside the territory after its name at the bottom of the screen. This feature makes it easy to see which units exist in the given territory, without having to open the battle calculator or switching to the territory tab (which loses focus on that territory when you move the mouse). This is especially helpful on some maps where there are small territories that can get cluttered with units and it's hard to see exactly what's there. This change makes it so the units in the territory under the cursor are shown at the bottom of the screen, in the same cell in the bottom bar where the territory name is. I've taken special care to test that this works well on a variety of maps and results in consistently good looking results. In particular: - The content of the cell continues to be centered. - On smaller screens, priority is given to the territory name / resources if not everything can fit. - It works well with a variety of image sizes and presence of larger images doesn't cause layout issues. - A menu item is added to toggle the display of this bar, so users who don't like it can turn it off. Tested manually resizing window to smaller and larger sizes to verify layouts on different screen sizes.
1 parent 485cc17 commit 7358e0a

File tree

6 files changed

+135
-58
lines changed

6 files changed

+135
-58
lines changed

game-app/game-core/src/main/java/games/strategy/triplea/ui/BottomBar.java

+85-40
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,30 @@
33
import games.strategy.engine.data.GameData;
44
import games.strategy.engine.data.GamePlayer;
55
import games.strategy.engine.data.Resource;
6-
import games.strategy.engine.data.ResourceCollection;
76
import games.strategy.engine.data.Territory;
87
import games.strategy.engine.data.TerritoryEffect;
98
import games.strategy.triplea.Constants;
109
import games.strategy.triplea.attachments.TerritoryAttachment;
10+
import games.strategy.triplea.util.UnitCategory;
11+
import games.strategy.triplea.util.UnitSeparator;
1112
import java.awt.BorderLayout;
1213
import java.awt.Dimension;
14+
import java.awt.Font;
1315
import java.awt.GridBagLayout;
1416
import java.awt.Image;
17+
import java.util.Collection;
1518
import java.util.List;
1619
import java.util.Optional;
20+
import javax.annotation.Nullable;
1721
import javax.swing.BorderFactory;
22+
import javax.swing.Box;
23+
import javax.swing.BoxLayout;
1824
import javax.swing.ImageIcon;
1925
import javax.swing.JLabel;
2026
import javax.swing.JPanel;
27+
import javax.swing.JSeparator;
2128
import javax.swing.SwingConstants;
29+
import javax.swing.border.Border;
2230
import javax.swing.border.EtchedBorder;
2331
import org.triplea.java.collections.IntegerMap;
2432
import org.triplea.swing.SwingComponents;
@@ -42,26 +50,24 @@ public class BottomBar extends JPanel {
4250
public BottomBar(final UiContext uiContext, final GameData data, final boolean usingDiceServer) {
4351
this.uiContext = uiContext;
4452
this.data = data;
45-
setLayout(new BorderLayout());
53+
this.resourceBar = new ResourceBar(data, uiContext);
4654

47-
resourceBar = new ResourceBar(data, uiContext);
55+
setLayout(new BorderLayout());
4856
add(createCenterPanel(), BorderLayout.CENTER);
4957
add(createStepPanel(usingDiceServer), BorderLayout.EAST);
5058
}
5159

5260
private JPanel createCenterPanel() {
5361
final JPanel centerPanel = new JPanel();
5462
centerPanel.setLayout(new GridBagLayout());
55-
centerPanel.setBorder(BorderFactory.createEmptyBorder());
5663
final var gridBuilder =
5764
new GridBagConstraintsBuilder(0, 0).weightY(1).fill(GridBagConstraintsFill.BOTH);
5865

5966
centerPanel.add(
6067
resourceBar, gridBuilder.weightX(0).anchor(GridBagConstraintsAnchor.WEST).build());
6168

62-
territoryInfo.setLayout(new GridBagLayout());
63-
territoryInfo.setBorder(new EtchedBorder(EtchedBorder.RAISED));
6469
territoryInfo.setPreferredSize(new Dimension(0, 0));
70+
territoryInfo.setBorder(new EtchedBorder(EtchedBorder.RAISED));
6571
centerPanel.add(
6672
territoryInfo,
6773
gridBuilder.gridX(1).weightX(1).anchor(GridBagConstraintsAnchor.CENTER).build());
@@ -102,65 +108,104 @@ public void setStatus(final String msg, final Optional<Image> image) {
102108
}
103109
}
104110

105-
public void setTerritory(final Territory territory) {
111+
public void setTerritory(final @Nullable Territory territory) {
106112
territoryInfo.removeAll();
107113

108-
final JLabel nameLabel = new JLabel();
109-
if (territory != null) {
110-
nameLabel.setText("<html><b>" + territory.getName());
111-
}
112-
113-
final var gridBuilder = new GridBagConstraintsBuilder(0, 0);
114-
// If territory is null or doesn't have an attachment then just display the name or "none"
115-
if (territory == null || TerritoryAttachment.get(territory) == null) {
116-
territoryInfo.add(nameLabel, gridBuilder.build());
114+
if (territory == null) {
117115
SwingComponents.redraw(territoryInfo);
118116
return;
119117
}
120118

121-
// Display territory effects, territory name, and resources
119+
// Box layout with horizontal glue on both sides achieves the following desirable properties:
120+
// 1. If the content is narrower than the available space, it will be centered.
121+
// 2. If the content is wider than the available space, then the beginning will be shown,
122+
// which is the more important information (territory name, income, etc).
123+
// 3. Elements are vertically centered.
124+
territoryInfo.setLayout(new BoxLayout(territoryInfo, BoxLayout.LINE_AXIS));
125+
territoryInfo.add(Box.createHorizontalGlue());
126+
122127
final TerritoryAttachment ta = TerritoryAttachment.get(territory);
123-
final List<TerritoryEffect> territoryEffects = ta.getTerritoryEffect();
124-
int count = 0;
128+
129+
// Display territory effects, territory name, resources and units.
125130
final StringBuilder territoryEffectText = new StringBuilder();
126-
for (final TerritoryEffect territoryEffect : territoryEffects) {
131+
final List<TerritoryEffect> territoryEffects = ta != null ? ta.getTerritoryEffect() : List.of();
132+
for (final TerritoryEffect effect : territoryEffects) {
127133
try {
128-
final JLabel territoryEffectLabel = new JLabel();
129-
territoryEffectLabel.setToolTipText(territoryEffect.getName());
130-
territoryEffectLabel.setIcon(
131-
uiContext.getTerritoryEffectImageFactory().getIcon(territoryEffect.getName()));
132-
territoryEffectLabel.setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 10));
133-
territoryInfo.add(territoryEffectLabel, gridBuilder.gridX(count++).build());
134+
final JLabel label = new JLabel();
135+
label.setToolTipText(effect.getName());
136+
label.setIcon(uiContext.getTerritoryEffectImageFactory().getIcon(effect.getName()));
137+
territoryInfo.add(label);
138+
territoryInfo.add(Box.createHorizontalStrut(6));
134139
} catch (final IllegalStateException e) {
135-
territoryEffectText.append(territoryEffect.getName()).append(", ");
140+
territoryEffectText.append(effect.getName()).append(", ");
136141
}
137142
}
138143

139-
territoryInfo.add(nameLabel, gridBuilder.gridX(count++).build());
144+
territoryInfo.add(createTerritoryNameLabel(territory.getName()));
140145

141146
if (territoryEffectText.length() > 0) {
142147
territoryEffectText.setLength(territoryEffectText.length() - 2);
143-
final JLabel territoryEffectTextLabel = new JLabel();
144-
territoryEffectTextLabel.setText(" (" + territoryEffectText + ")");
145-
territoryInfo.add(territoryEffectTextLabel, gridBuilder.gridX(count++).build());
148+
final JLabel territoryEffectTextLabel = new JLabel(" (" + territoryEffectText + ")");
149+
territoryInfo.add(territoryEffectTextLabel);
150+
}
151+
152+
Optional.ofNullable(ta).ifPresent(this::addTerritoryResourceDetails);
153+
154+
if (uiContext.isShowUnitsInStatusBar()) {
155+
final Collection<UnitCategory> units = UnitSeparator.categorize(territory.getUnits());
156+
if (!units.isEmpty()) {
157+
JSeparator separator = new JSeparator(JSeparator.VERTICAL);
158+
separator.setMaximumSize(new Dimension(40, getHeight()));
159+
separator.setPreferredSize(separator.getMaximumSize());
160+
territoryInfo.add(separator);
161+
territoryInfo.add(createUnitBar(units));
162+
}
146163
}
147164

165+
territoryInfo.add(Box.createHorizontalGlue());
166+
SwingComponents.redraw(territoryInfo);
167+
}
168+
169+
private JLabel createTerritoryNameLabel(String territoryName) {
170+
final JLabel nameLabel = new JLabel(territoryName);
171+
nameLabel.setFont(nameLabel.getFont().deriveFont(Font.BOLD));
172+
// Ensure the text position is always the same, regardless of other components, by padding to
173+
// fill available height.
174+
final int labelHeight = nameLabel.getPreferredSize().height;
175+
nameLabel.setBorder(createBorderToFillAvailableHeight(labelHeight, getHeight()));
176+
return nameLabel;
177+
}
178+
179+
private Border createBorderToFillAvailableHeight(int componentHeight, int availableHeight) {
180+
int extraVerticalSpace = Math.max(availableHeight - componentHeight, 0);
181+
int topPad = extraVerticalSpace / 2;
182+
int bottomPad = extraVerticalSpace - topPad; // Might != topPad if extraVerticalSpace is odd.
183+
return BorderFactory.createEmptyBorder(topPad, 0, bottomPad, 0);
184+
}
185+
186+
private void addTerritoryResourceDetails(TerritoryAttachment ta) {
148187
final IntegerMap<Resource> resources = new IntegerMap<>();
149188
final int production = ta.getProduction();
150189
if (production > 0) {
151190
resources.add(new Resource(Constants.PUS, data), production);
152191
}
153-
final ResourceCollection resourceCollection = ta.getResources();
154-
if (resourceCollection != null) {
155-
resources.add(resourceCollection.getResourcesCopy());
156-
}
192+
Optional.ofNullable(ta.getResources()).ifPresent(r -> resources.add(r.getResourcesCopy()));
157193
for (final Resource resource : resources.keySet()) {
158-
final JLabel resourceLabel =
159-
uiContext.getResourceImageFactory().getLabel(resource, resources);
160-
resourceLabel.setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 0));
161-
territoryInfo.add(resourceLabel, gridBuilder.gridX(count++).build());
194+
territoryInfo.add(Box.createHorizontalStrut(6));
195+
territoryInfo.add(uiContext.getResourceImageFactory().getLabel(resource, resources));
162196
}
163-
SwingComponents.redraw(territoryInfo);
197+
}
198+
199+
private SimpleUnitPanel createUnitBar(Collection<UnitCategory> units) {
200+
final var unitBar = new SimpleUnitPanel(uiContext, SimpleUnitPanel.Style.SMALL_ICONS_ROW);
201+
unitBar.setScaleFactor(0.5);
202+
unitBar.setShowCountsForSingleUnits(false);
203+
unitBar.setUnitsFromCategories(units);
204+
// Constrain the preferred size to the available size so that unit images that may not fully fit
205+
// don't cause layout issues.
206+
final int unitsWidth = unitBar.getPreferredSize().width;
207+
unitBar.setPreferredSize(new Dimension(unitsWidth, getHeight()));
208+
return unitBar;
164209
}
165210

166211
public void gameDataChanged() {

game-app/game-core/src/main/java/games/strategy/triplea/ui/ResourceBar.java

+2-5
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,12 @@ public void gameDataChanged(final Change change) {
6969
updateScheduled = false;
7070
final GamePlayer player;
7171
final IntegerMap<Resource> resourceIncomes;
72-
try {
73-
gameData.acquireReadLock();
72+
try (GameData.Unlocker ignored = gameData.acquireReadLock()) {
7473
player = gameData.getSequence().getStep().getPlayerId();
7574
if (player == null) {
7675
return;
7776
}
7877
resourceIncomes = AbstractEndTurnDelegate.findEstimatedIncome(player, gameData);
79-
} finally {
80-
gameData.releaseReadLock();
8178
}
8279
SwingUtilities.invokeLater(
8380
() -> {
@@ -95,7 +92,7 @@ public void gameDataChanged(final Change change) {
9592
text.append(" (").append(income >= 0 ? "+" : "").append(income).append(")");
9693
final JLabel label =
9794
uiContext.getResourceImageFactory().getLabel(resource, text.toString());
98-
label.setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 10));
95+
label.setBorder(BorderFactory.createEmptyBorder(0, 6, 0, 6));
9996
add(label, new GridBagConstraintsBuilder(count++, 0).weightY(1).build());
10097
}
10198
});

game-app/game-core/src/main/java/games/strategy/triplea/ui/SimpleUnitPanel.java

+30-7
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,18 @@
1313
import games.strategy.triplea.delegate.Matches;
1414
import games.strategy.triplea.image.UnitImageFactory;
1515
import games.strategy.triplea.util.UnitCategory;
16+
import java.awt.Image;
1617
import java.util.Collection;
1718
import java.util.Map;
1819
import java.util.Optional;
1920
import java.util.Set;
2021
import java.util.TreeSet;
22+
import javax.swing.Box;
2123
import javax.swing.BoxLayout;
2224
import javax.swing.ImageIcon;
2325
import javax.swing.JLabel;
2426
import javax.swing.JPanel;
27+
import lombok.Setter;
2528
import lombok.extern.slf4j.Slf4j;
2629
import org.triplea.java.collections.IntegerMap;
2730
import org.triplea.swing.WrapLayout;
@@ -32,9 +35,12 @@ public class SimpleUnitPanel extends JPanel {
3235
private static final long serialVersionUID = -3768796793775300770L;
3336
private final UiContext uiContext;
3437
private final Style style;
38+
@Setter private double scaleFactor = 1.0;
39+
@Setter private boolean showCountsForSingleUnits = true;
3540

3641
public enum Style {
3742
LARGE_ICONS_COLUMN,
43+
SMALL_ICONS_ROW,
3844
SMALL_ICONS_WRAPPED_WITH_LABEL_WHEN_EMPTY
3945
}
4046

@@ -47,6 +53,8 @@ public SimpleUnitPanel(final UiContext uiContext, final Style style) {
4753
this.style = style;
4854
if (style == Style.SMALL_ICONS_WRAPPED_WITH_LABEL_WHEN_EMPTY) {
4955
setLayout(new WrapLayout());
56+
} else if (style == Style.SMALL_ICONS_ROW) {
57+
setLayout(new BoxLayout(this, BoxLayout.X_AXIS));
5058
} else {
5159
setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
5260
}
@@ -132,30 +140,45 @@ private void addUnits(
132140
final boolean damaged,
133141
final boolean disabled) {
134142
final JLabel label = new JLabel();
135-
label.setText(" x " + quantity);
143+
if (showCountsForSingleUnits || quantity > 1) {
144+
label.setText("x " + quantity);
145+
}
136146
if (unit instanceof UnitType) {
137147
final UnitType unitType = (UnitType) unit;
138-
139148
final UnitImageFactory.ImageKey imageKey =
140149
UnitImageFactory.ImageKey.builder()
141150
.player(player)
142151
.type(unitType)
143152
.damaged(damaged)
144153
.disabled(disabled)
145154
.build();
146-
final Optional<ImageIcon> icon = uiContext.getUnitImageFactory().getIcon(imageKey);
147-
if (icon.isEmpty() && !uiContext.isShutDown()) {
155+
Optional<ImageIcon> icon = uiContext.getUnitImageFactory().getIcon(imageKey);
156+
if (icon.isPresent()) {
157+
label.setIcon(scaleIcon(icon.get(), scaleFactor));
158+
} else if (!uiContext.isShutDown()) {
148159
final String imageName = imageKey.getFullName();
149160
log.error("missing unit icon (won't be displayed): " + imageName + ", " + imageKey);
150161
}
151-
icon.ifPresent(label::setIcon);
152162
MapUnitTooltipManager.setUnitTooltip(label, unitType, player, quantity);
153163
} else if (unit instanceof Resource) {
154-
label.setIcon(
164+
ImageIcon icon =
155165
style == Style.LARGE_ICONS_COLUMN
156166
? uiContext.getResourceImageFactory().getLargeIcon(unit.getName())
157-
: uiContext.getResourceImageFactory().getIcon(unit.getName()));
167+
: uiContext.getResourceImageFactory().getIcon(unit.getName());
168+
label.setIcon(scaleIcon(icon, scaleFactor));
158169
}
159170
add(label);
171+
if (style == Style.SMALL_ICONS_ROW) {
172+
add(Box.createHorizontalStrut(8));
173+
}
174+
}
175+
176+
private static ImageIcon scaleIcon(ImageIcon icon, double scaleFactor) {
177+
if (scaleFactor == 1.0) {
178+
return icon;
179+
}
180+
int width = (int) (icon.getIconWidth() * scaleFactor);
181+
int height = (int) (icon.getIconHeight() * scaleFactor);
182+
return new ImageIcon(icon.getImage().getScaledInstance(width, height, Image.SCALE_SMOOTH));
160183
}
161184
}

game-app/game-core/src/main/java/games/strategy/triplea/ui/TripleAFrame.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ public final class TripleAFrame extends JFrame implements QuitHandler {
199199
@Getter private final ButtonModel editModeButtonModel;
200200
@Getter private IEditDelegate editDelegate;
201201
private final JSplitPane gameCenterPanel;
202-
private final BottomBar bottomBar;
202+
@Getter private final BottomBar bottomBar;
203203
private GamePlayer lastStepPlayer;
204204
private GamePlayer currentStepPlayer;
205205
private final Map<GamePlayer, Boolean> requiredTurnSeries = new HashMap<>();

game-app/game-core/src/main/java/games/strategy/triplea/ui/UiContext.java

+1
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ public class UiContext {
7474
private final DiceImageFactory diceImageFactory;
7575
private final PuImageFactory puImageFactory = new PuImageFactory();
7676
private boolean drawUnits = true;
77+
@Getter @Setter private boolean showUnitsInStatusBar = true;
7778
private boolean drawTerritoryEffects;
7879

7980
@Getter private Cursor cursor;

game-app/game-core/src/main/java/games/strategy/triplea/ui/menubar/ViewMenu.java

+16-5
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ final class ViewMenu extends JMenu {
7474
addUnitSizeMenu();
7575
addLockMap();
7676
addShowUnitsMenu();
77+
addShowUnitsInStatusBarMenu();
7778
addFlagDisplayModeMenu();
7879

7980
if (uiContext.getMapData().useTerritoryEffectMarkers()) {
@@ -213,7 +214,7 @@ public void actionPerformed(final ActionEvent e) {
213214
unitSizeGroup.add(radioItem56);
214215
unitSizeGroup.add(radioItem50);
215216
radioItem100.setSelected(true);
216-
// select the closest to to the default size
217+
// select the closest to the default size
217218
final Enumeration<AbstractButton> enum1 = unitSizeGroup.getElements();
218219
boolean matchFound = false;
219220
while (enum1.hasMoreElements()) {
@@ -317,13 +318,24 @@ private void addShowUnitsMenu() {
317318
showUnitsBox.setSelected(true);
318319
showUnitsBox.addActionListener(
319320
e -> {
320-
final boolean tfselected = showUnitsBox.isSelected();
321-
uiContext.setShowUnits(tfselected);
321+
uiContext.setShowUnits(showUnitsBox.isSelected());
322322
frame.getMapPanel().resetMap();
323323
});
324324
add(showUnitsBox);
325325
}
326326

327+
private void addShowUnitsInStatusBarMenu() {
328+
JCheckBoxMenuItem checkbox = new JCheckBoxMenuItem("Show Units in Status Bar");
329+
checkbox.setSelected(true);
330+
checkbox.addActionListener(
331+
e -> {
332+
uiContext.setShowUnitsInStatusBar(checkbox.isSelected());
333+
// Trigger a bottom bar update.
334+
frame.getBottomBar().setTerritory(frame.getMapPanel().getCurrentTerritory());
335+
});
336+
add(checkbox);
337+
}
338+
327339
private void addMapFontAndColorEditorMenu() {
328340
final Action mapFontOptions =
329341
SwingAction.of(
@@ -423,8 +435,7 @@ private void addShowTerritoryEffects() {
423435
territoryEffectsBox.setMnemonic(KeyEvent.VK_T);
424436
territoryEffectsBox.addActionListener(
425437
e -> {
426-
final boolean tfselected = territoryEffectsBox.isSelected();
427-
uiContext.setShowTerritoryEffects(tfselected);
438+
uiContext.setShowTerritoryEffects(territoryEffectsBox.isSelected());
428439
frame.getMapPanel().resetMap();
429440
});
430441
add(territoryEffectsBox);

0 commit comments

Comments
 (0)