diff --git a/forge-gui-desktop/src/main/java/forge/gui/ListChooser.java b/forge-gui-desktop/src/main/java/forge/gui/ListChooser.java index 56d9063b35c..065e43d1118 100644 --- a/forge-gui-desktop/src/main/java/forge/gui/ListChooser.java +++ b/forge-gui-desktop/src/main/java/forge/gui/ListChooser.java @@ -28,14 +28,17 @@ import java.util.Collection; import java.util.Comparator; import java.util.List; +import java.util.Objects; import java.util.function.Function; import javax.swing.AbstractListModel; import javax.swing.DefaultListCellRenderer; import javax.swing.ImageIcon; +import javax.swing.JComponent; import javax.swing.JList; import javax.swing.JPanel; import javax.swing.ListCellRenderer; +import javax.swing.ListModel; import javax.swing.ListSelectionModel; import javax.swing.SwingUtilities; import javax.swing.WindowConstants; @@ -50,6 +53,7 @@ import forge.card.CardType; import forge.card.MagicColor; +import forge.game.card.CardView; import forge.game.card.CounterKeywordType; import forge.game.card.CounterType; import forge.localinstance.skin.FSkinProp; @@ -59,6 +63,7 @@ import forge.toolbox.FScrollPane; import forge.toolbox.FSkin; import forge.toolbox.FTextField; +import forge.toolbox.special.CardImageGrid; import forge.util.ITranslatable; import forge.util.Localizer; import org.apache.commons.lang3.StringUtils; @@ -88,6 +93,11 @@ * @version $Id: ListChooser.java 25183 2014-03-14 23:09:45Z drdev $ */ public class ListChooser { + private static final int GRID_THUMB_W = 200; + private static final int GRID_THUMB_H = 280; + private static final int GRID_MAX_COLUMNS = 4; + private static final int GRID_MAX_VISIBLE_ROWS = 3; + // Data and number of choices for the list private final List allItems; private List displayedItems; @@ -101,6 +111,8 @@ public class ListChooser { private final FList lstChoices; private final FOptionPane optionPane; private final ChooserListModel listModel; + // Non-null only in card-grid mode; keeps a typed handle for selection-restore in show() + private final CardImageGrid cardGrid; public ListChooser(final String title, final int minChoices, final int maxChoices, final Collection list, final Function display) { FThreads.assertExecutedByEdt(true); @@ -110,7 +122,6 @@ public ListChooser(final String title, final int minChoices, final int maxChoice this.allItems = list.getClass().isInstance(List.class) ? (List)list : Lists.newArrayList(list); this.displayedItems = new ArrayList<>(this.allItems); this.listModel = new ChooserListModel(); - this.lstChoices = new FList<>(this.listModel); final ImmutableList options; if (minChoices == 0) { @@ -119,56 +130,76 @@ public ListChooser(final String title, final int minChoices, final int maxChoice options = ImmutableList.of(Localizer.getInstance().getMessage("lblOK")); } - if (maxChoices == 1 || minChoices == -1) { - this.lstChoices.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - } - - this.lstChoices.setCellRenderer(new TransformedCellRenderer(display)); + final JComponent body; + // Tracks the focus target for setDefaultFocus below. Text-mode large lists focus the search + // field so the user can start typing immediately; otherwise focus the list / grid itself. + final JComponent defaultFocus; + if (isCardChoiceView(allItems, maxChoices)) { + final int cols = Math.max(1, Math.min(allItems.size(), GRID_MAX_COLUMNS)); + this.cardGrid = CardImageGrid.forCardViews(cols, GRID_THUMB_W, GRID_THUMB_H); + @SuppressWarnings("unchecked") + final List cardItems = (List) allItems; + this.cardGrid.setItems(cardItems); + @SuppressWarnings("unchecked") + final FList gridList = (FList) (FList) this.cardGrid.getList(); + this.lstChoices = gridList; + body = cardGrid.makeFixedSizeContainer(GRID_MAX_VISIBLE_ROWS); + defaultFocus = this.lstChoices; + } else { + this.cardGrid = null; + this.lstChoices = new FList<>(this.listModel); - final FScrollPane listScroller = new FScrollPane(this.lstChoices, true); - int minWidth = this.lstChoices.getAutoSizeWidth(); - if (this.lstChoices.getModel().getSize() > this.lstChoices.getVisibleRowCount()) { - minWidth += listScroller.getVerticalScrollBar().getPreferredSize().width; - } - listScroller.setMinimumSize(new Dimension(minWidth, listScroller.getMinimumSize().height)); - - // Add search field for large lists (same threshold as mobile) - if (allItems.size() > 25) { - final FTextField searchField = new FTextField.Builder() - .ghostText(Localizer.getInstance().getMessage("lblSearch")) - .showGhostTextWithFocus() - .build(); - searchField.getDocument().addDocumentListener(new DocumentListener() { - @Override public void insertUpdate(DocumentEvent e) { applyFilter(searchField); } - @Override public void removeUpdate(DocumentEvent e) { applyFilter(searchField); } - @Override public void changedUpdate(DocumentEvent e) { applyFilter(searchField); } - }); - searchField.addKeyListener(new KeyAdapter() { - @Override public void keyPressed(final KeyEvent e) { - if (e.getKeyCode() == KeyEvent.VK_ENTER) { - ListChooser.this.commit(); - } else if (e.getKeyCode() == KeyEvent.VK_DOWN) { - lstChoices.requestFocusInWindow(); - } - } - }); + if (maxChoices == 1 || minChoices == -1) { + this.lstChoices.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + } - final JPanel panel = new JPanel(new BorderLayout(0, 4)); - panel.setOpaque(false); - panel.add(searchField, BorderLayout.NORTH); - panel.add(listScroller, BorderLayout.CENTER); + this.lstChoices.setCellRenderer(new TransformedCellRenderer(display)); - this.optionPane = new FOptionPane(null, title, null, panel, options, minChoices < 0 ? 0 : -1); - if (minChoices != -1) { - this.optionPane.setDefaultFocus(searchField); + final FScrollPane listScroller = new FScrollPane(this.lstChoices, true); + int minWidth = this.lstChoices.getAutoSizeWidth(); + if (this.lstChoices.getModel().getSize() > this.lstChoices.getVisibleRowCount()) { + minWidth += listScroller.getVerticalScrollBar().getPreferredSize().width; } - } else { - this.optionPane = new FOptionPane(null, title, null, listScroller, options, minChoices < 0 ? 0 : -1); - if (minChoices != -1) { - this.optionPane.setDefaultFocus(this.lstChoices); + listScroller.setMinimumSize(new Dimension(minWidth, listScroller.getMinimumSize().height)); + + // Add search field for large lists (same threshold as mobile) + if (allItems.size() > 25) { + final FTextField searchField = new FTextField.Builder() + .ghostText(Localizer.getInstance().getMessage("lblSearch")) + .showGhostTextWithFocus() + .build(); + searchField.getDocument().addDocumentListener(new DocumentListener() { + @Override public void insertUpdate(DocumentEvent e) { applyFilter(searchField); } + @Override public void removeUpdate(DocumentEvent e) { applyFilter(searchField); } + @Override public void changedUpdate(DocumentEvent e) { applyFilter(searchField); } + }); + searchField.addKeyListener(new KeyAdapter() { + @Override public void keyPressed(final KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_ENTER) { + ListChooser.this.commit(); + } else if (e.getKeyCode() == KeyEvent.VK_DOWN) { + lstChoices.requestFocusInWindow(); + } + } + }); + + final JPanel panel = new JPanel(new BorderLayout(0, 4)); + panel.setOpaque(false); + panel.add(searchField, BorderLayout.NORTH); + panel.add(listScroller, BorderLayout.CENTER); + body = panel; + defaultFocus = searchField; + } else { + body = listScroller; + defaultFocus = this.lstChoices; } } + this.optionPane = new FOptionPane(null, title, null, body, options, minChoices < 0 ? 0 : -1); + if (minChoices != -1) { + this.optionPane.setDefaultFocus(defaultFocus); + } + this.optionPane.setButtonEnabled(0, minChoices <= 0); if (minChoices > 0) { @@ -193,6 +224,18 @@ public ListChooser(final String title, final int minChoices, final int maxChoice }); } + private static boolean isCardChoiceView(Collection list, int maxChoices) { + if (list.isEmpty() || maxChoices > 1) { + return false; + } + for (Object o : list) { + if (!(o instanceof CardView)) { + return false; + } + } + return true; + } + private void applyFilter(final FTextField searchField) { final String text = normalize(searchField.getText()); lstChoices.clearSelection(); @@ -236,6 +279,16 @@ private String getDisplayText(final T value) { return value != null ? value.toString() : ""; } + private int indexOfInModel(final T t) { + final ListModel m = lstChoices.getModel(); + for (int i = 0; i < m.getSize(); i++) { + if (Objects.equals(m.getElementAt(i), t)) { + return i; + } + } + return -1; + } + /** * Returns the FList used in the list chooser. this is useful for * registering listeners before showing the dialog. @@ -263,11 +316,14 @@ public boolean show(final Collection item) { //invoke later so selected item not set until dialog open SwingUtilities.invokeLater(() -> { if (item != null) { - int[] indices = item.stream() - .mapToInt(displayedItems::indexOf) + final int[] indices = item.stream() + .mapToInt(this::indexOfInModel) .filter(i -> i >= 0) .toArray(); lstChoices.setSelectedIndices(indices); + if (indices.length > 0) { + lstChoices.ensureIndexIsVisible(indices[0]); + } } else { lstChoices.setSelectedIndex(0); diff --git a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/ChangePrintingDialog.java b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/ChangePrintingDialog.java index 9d89ec639ba..f824c2af3dd 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/ChangePrintingDialog.java +++ b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/ChangePrintingDialog.java @@ -32,7 +32,9 @@ public final class ChangePrintingDialog { private static final int SCROLLBAR_W = 16; // Empirical buffer: FScrollPane's themed border eats ~4px; without this, 4*cellW+SCROLLBAR_W rounds down to 3 cols. private static final int VIEWPORT_BUFFER = 12; - private static final int CONTAINER_H = 736; // 700 grid + 28 search bar + 8 gap + private static final int VISIBLE_ROWS = 2; + private static final int TOPBAR_H = 28; + private static final int TOPBAR_GAP = 8; private static final int SEARCH_DEBOUNCE_MS = 200; private ChangePrintingDialog() {} @@ -52,7 +54,7 @@ public static PaperCard show(final PaperCard current) { printings.add(0, currentNonFoil); } - final CardImageGrid grid = new CardImageGrid(COLUMNS, THUMB_W, THUMB_H); + final CardImageGrid grid = CardImageGrid.forPaperCards(COLUMNS, THUMB_W, THUMB_H); grid.setItems(printings); grid.setSelected(currentNonFoil); @@ -97,7 +99,8 @@ public static PaperCard show(final PaperCard current) { topBar.add(cbStyle, BorderLayout.EAST); final int containerW = COLUMNS * grid.getCellWidth() + SCROLLBAR_W + VIEWPORT_BUFFER; - final Dimension fixedSize = new Dimension(containerW, CONTAINER_H); + final int containerH = VISIBLE_ROWS * grid.getCellHeight() + TOPBAR_H + TOPBAR_GAP; + final Dimension fixedSize = new Dimension(containerW, containerH); final JPanel container = new JPanel(new BorderLayout(0, 8)) { private static final long serialVersionUID = 1L; @Override public Dimension getPreferredSize() { return fixedSize; } diff --git a/forge-gui-desktop/src/main/java/forge/toolbox/special/CardImageGrid.java b/forge-gui-desktop/src/main/java/forge/toolbox/special/CardImageGrid.java index 964b387939b..cec3c86f58f 100644 --- a/forge-gui-desktop/src/main/java/forge/toolbox/special/CardImageGrid.java +++ b/forge-gui-desktop/src/main/java/forge/toolbox/special/CardImageGrid.java @@ -1,11 +1,13 @@ package forge.toolbox.special; import java.awt.BorderLayout; +import java.awt.Color; import java.awt.Component; import java.awt.Dimension; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.Image; +import java.awt.Rectangle; import java.awt.RenderingHints; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; @@ -33,39 +35,55 @@ import forge.ImageCache; import forge.StaticData; import forge.card.CardEdition; +import forge.game.card.CardView; import forge.gui.GuiBase; import forge.item.PaperCard; import forge.toolbox.FList; import forge.toolbox.FScrollPane; import forge.toolbox.IDisposable; import forge.util.ImageFetcher; +import forge.view.arcane.CardPanel; /** - * Reusable Swing grid of card thumbnails. Each cell shows a card image with the edition name, - * code, and collector number underneath. The widget is filter-agnostic — callers compose their - * own search bar over {@link #setItems}. + * Reusable Swing grid of card thumbnails. Each cell shows a card image with a footer underneath. + * Filter-agnostic — callers compose their own search bar over {@link #setItems}. */ -public final class CardImageGrid implements IDisposable { +public final class CardImageGrid implements IDisposable { + + public interface CellAdapter { + String imageKey(T item); + String footerHtml(T item); + default int footerHeight() { return 60; } + } + + private static final int SCROLLBAR_W = 16; + // FScrollPane themed-border slack — without it AS_NEEDED triggers and HORIZONTAL_WRAP drops a column. + private static final int VIEWPORT_BUFFER = 12; private final int thumbW; private final int thumbH; + private final int footerH; private final int cellW; private final int cellH; private final int columns; + private final CellAdapter adapter; - private final DefaultListModel model = new DefaultListModel<>(); - private final JList list; + private final DefaultListModel model = new DefaultListModel<>(); + private final FList list; private final FScrollPane scrollPane; private final Map iconCache = new HashMap<>(); - public CardImageGrid(int columns, int thumbW, int thumbH) { + public CardImageGrid(int columns, int thumbW, int thumbH, CellAdapter adapter) { this.columns = columns; this.thumbW = thumbW; this.thumbH = thumbH; + this.footerH = adapter.footerHeight(); this.cellW = thumbW + 16; - this.cellH = thumbH + 70; + // 20 = 8+4+8 chrome (top border, vgap, bottom border); less and the icon overflows imageLabel, clipping the selection ring corners. + this.cellH = thumbH + footerH + 20; + this.adapter = adapter; - this.list = new FList(model) { + this.list = new FList(model) { private static final long serialVersionUID = 1L; @Override @@ -88,17 +106,29 @@ public Dimension getPreferredSize() { list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); this.scrollPane = new FScrollPane(list, true, - ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS, + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); scrollPane.setWheelScrollingEnabled(true); list.addMouseWheelListener(e -> scrollPane.dispatchEvent(SwingUtilities.convertMouseEvent(list, e, scrollPane))); } + public static CardImageGrid forPaperCards(int cols, int thumbW, int thumbH) { + return new CardImageGrid<>(cols, thumbW, thumbH, new PaperCardCellAdapter()); + } + + public static CardImageGrid forCardViews(int cols, int thumbW, int thumbH) { + return new CardImageGrid<>(cols, thumbW, thumbH, new CardViewCellAdapter()); + } + public JComponent getComponent() { return scrollPane; } + public FList getList() { + return list; + } + public int getCellWidth() { return cellW; } @@ -107,12 +137,31 @@ public int getCellHeight() { return cellH; } + public JComponent makeFixedSizeContainer(int maxVisibleRows) { + final int rows = Math.max(1, (model.getSize() + columns - 1) / columns); + final int visibleRows = Math.min(rows, maxVisibleRows); + final boolean needsScroll = rows > maxVisibleRows; + final int scrollbarReservation = needsScroll ? SCROLLBAR_W : 0; + final Dimension fixedSize = new Dimension( + columns * cellW + scrollbarReservation + VIEWPORT_BUFFER, + visibleRows * cellH + VIEWPORT_BUFFER); + final JPanel container = new JPanel(new BorderLayout()) { + private static final long serialVersionUID = 1L; + @Override public Dimension getPreferredSize() { return fixedSize; } + @Override public Dimension getMinimumSize() { return fixedSize; } + @Override public Dimension getMaximumSize() { return fixedSize; } + }; + container.setOpaque(false); + container.add(scrollPane, BorderLayout.CENTER); + return container; + } + /** Replace the items, preserving selection when the previously-selected card is still present. */ - public void setItems(List items) { - final PaperCard previous = list.getSelectedValue(); + public void setItems(List items) { + final T previous = list.getSelectedValue(); model.clear(); - for (PaperCard pc : items) { - model.addElement(pc); + for (T item : items) { + model.addElement(item); } if (previous != null) { final int idx = model.indexOf(previous); @@ -126,24 +175,24 @@ public boolean isEmpty() { return model.isEmpty(); } - public PaperCard getSelected() { + public T getSelected() { return list.getSelectedValue(); } - public void setSelected(PaperCard pc) { - final int idx = model.indexOf(pc); + public void setSelected(T item) { + final int idx = model.indexOf(item); if (idx >= 0) { list.setSelectedIndex(idx); list.ensureIndexIsVisible(idx); } } - public void addDoubleClickListener(Consumer listener) { + public void addDoubleClickListener(Consumer listener) { list.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { if (e.getClickCount() == 2) { - final PaperCard sel = list.getSelectedValue(); + final T sel = list.getSelectedValue(); if (sel != null) { listener.accept(sel); } @@ -157,11 +206,7 @@ public void dispose() { iconCache.clear(); } - /** - * Paints a high-resolution source image at smaller display size using BICUBIC against the destination Graphics2D. - * Routes through the screen's HiDPI transform — pre-rendering into a user-space BufferedImage would let Swing - * add a bilinear upscale and blur the output. - */ + // Draws against the destination Graphics2D so HiDPI scaling stays in one step; pre-rendering to a BufferedImage adds a blurring upscale. private static final class HighQualityScaledIcon implements Icon { private final Image source; private final int displayW; @@ -179,18 +224,50 @@ private static final class HighQualityScaledIcon implements Icon { @Override public void paintIcon(Component c, Graphics g, int x, int y) { final Graphics2D g2 = (Graphics2D) g.create(); - g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g2.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); g2.drawImage(source, x, y, displayW, displayH, null); g2.dispose(); } } - private final class GridCellRenderer extends JPanel implements ListCellRenderer { + private static final class PaperCardCellAdapter implements CellAdapter { + @Override + public String imageKey(PaperCard pc) { + return pc.getImageKey(false); + } + @Override + public String footerHtml(PaperCard pc) { + final String editionCode = pc.getEdition(); + final CardEdition edition = StaticData.instance().getEditions().get(editionCode); + final String editionName = edition != null ? edition.getName() : editionCode; + return String.format("
%s
(%s) #%s
", + editionName, editionCode, pc.getCollectorNumber()); + } + } + + private static final class CardViewCellAdapter implements CellAdapter { + @Override + public String imageKey(CardView cv) { + return cv.getCurrentState().getImageKey(); + } + @Override + public String footerHtml(CardView cv) { + String name = cv.getCurrentState().getName(); + if (name == null || name.isEmpty()) { + name = "—"; + } + return String.format("
%s
", name); + } + @Override public int footerHeight() { return 24; } + } + + private final class GridCellRenderer extends JPanel implements ListCellRenderer { private static final long serialVersionUID = 1L; private final JLabel imageLabel = new JLabel(); private final JLabel textLabel = new JLabel(); + private boolean cellSelected; GridCellRenderer() { super(new BorderLayout(0, 4)); @@ -199,36 +276,53 @@ private final class GridCellRenderer extends JPanel implements ListCellRenderer< imageLabel.setHorizontalAlignment(SwingConstants.CENTER); textLabel.setHorizontalAlignment(SwingConstants.CENTER); textLabel.setVerticalAlignment(SwingConstants.TOP); - textLabel.setPreferredSize(new Dimension(thumbW, 60)); + textLabel.setPreferredSize(new Dimension(thumbW, footerH)); add(imageLabel, BorderLayout.CENTER); add(textLabel, BorderLayout.SOUTH); } @Override - public Component getListCellRendererComponent(JList source, - PaperCard value, int index, boolean isSelected, boolean cellHasFocus) { - final String editionCode = value.getEdition(); - final CardEdition edition = StaticData.instance().getEditions().get(editionCode); - final String editionName = edition != null ? edition.getName() : editionCode; - - textLabel.setText(String.format( - "
%s
(%s) #%s
", - editionName, editionCode, value.getCollectorNumber())); + public Component getListCellRendererComponent(JList source, + T value, int index, boolean isSelected, boolean cellHasFocus) { + textLabel.setText(adapter.footerHtml(value)); imageLabel.setIcon(getOrComputeIcon(value, source)); + setBackground(source.getBackground()); + textLabel.setForeground(source.getForeground()); + this.cellSelected = isSelected; + return this; + } - if (isSelected) { - setBackground(source.getSelectionBackground()); - textLabel.setForeground(source.getSelectionForeground()); - } else { - setBackground(source.getBackground()); - textLabel.setForeground(source.getForeground()); + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + + if (!cellSelected) { + return; + } + + final Rectangle b = imageLabel.getBounds(); + if (b.width <= 0 || b.height <= 0) { + return; + } + + // Inflated rounded rect drawn before the image label paints; only the outer ring stays visible. + final Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + final int n = Math.max(1, Math.round(b.width * CardPanel.SELECTED_BORDER_SIZE)); + final int corner = Math.max(4, Math.round(b.width * CardPanel.ROUNDED_CORNER_SIZE)); + g2.setColor(Color.green); + g2.fillRoundRect(b.x - n, b.y - n, + b.width + 2 * n, b.height + 2 * n, + corner + n, corner + n); + } finally { + g2.dispose(); } - return this; } - private Icon getOrComputeIcon(PaperCard card, JList source) { - final String imageKey = card.getImageKey(false); + private Icon getOrComputeIcon(T value, JList source) { + final String imageKey = adapter.imageKey(value); final String cacheKey = imageKey + "#" + thumbW + "x" + thumbH; Icon cached = iconCache.get(cacheKey); @@ -243,7 +337,10 @@ private Icon getOrComputeIcon(PaperCard card, JList source) return null; } - final Icon icon = new HighQualityScaledIcon(src, thumbW, thumbH); + // Bake corners at source resolution; clipping in paintIcon doesn't antialias the boundary on most JDKs. + final int srcCorner = Math.max(4, Math.round(src.getWidth() * CardPanel.ROUNDED_CORNER_SIZE)); + final BufferedImage rounded = ImageCache.makeRoundedCorner(src, srcCorner); + final Icon icon = new HighQualityScaledIcon(rounded, thumbW, thumbH); iconCache.put(cacheKey, icon); if (isPlaceholder) {