001package com.studentgui.uicomp; 002 003import java.awt.Dimension; 004import java.awt.Font; 005import java.awt.GridBagConstraints; 006import java.awt.GridBagLayout; 007import java.awt.Insets; 008 009import javax.swing.BorderFactory; 010import javax.swing.JComponent; 011import javax.swing.JPanel; 012import javax.swing.JSpinner; 013import javax.swing.JTextArea; 014import javax.swing.SpinnerNumberModel; 015 016/** 017 * Reusable component that renders a wrapped descriptive label and a compact 018 * integer input (0..4). The label is a non-editable JTextArea that wraps at 019 * ~200px; the component adds a 20px left inset so the label appears offset. 020 * The spinner is aligned to the first line of the label. 021 */ 022public class PhaseScoreField extends JPanel { 023 private static final long serialVersionUID = 1L; 024 private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(PhaseScoreField.class); 025 // Ensure we only log the font-adjustment debug once to avoid noisy output in headless tests. 026 private static final java.util.concurrent.atomic.AtomicBoolean FONT_ADJUST_LOGGED = new java.util.concurrent.atomic.AtomicBoolean(false); 027 /** Shared label font for all form labels so sizing stays consistent project-wide. */ 028 private static final Font DEFAULT_LABEL_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 28); 029 /** Wrapped, read-only label area used to display the description text. */ 030 private final JTextArea labelArea; 031 /** Numeric spinner used for 0..4 score entry. */ 032 private final JSpinner spinner; 033 /** Container used to constrain the wrap width of the label area. */ 034 private final JPanel labelWrap; 035 // Global label width (pixels) used to make all rows align; default ~200 036 // Note: intentionally non-final so pages can adjust it at runtime. 037 private static int globalLabelWidthPx = 200; 038 039 /** Horizontal spacer panel inserted to tune the gap between label and spinner. */ 040 private final JPanel spacer; 041 042 /** 043 * Return the shared label font used across all PhaseScoreField instances. 044 * 045 * @return default label font (24pt sans-serif) 046 */ 047 public static Font getLabelFont() { return DEFAULT_LABEL_FONT; } 048 049 /** 050 * Create a PhaseScoreField containing a wrapped label and a numeric spinner. 051 * 052 * @param labelText text label to display (may be multi-line) 053 * @param initial initial integer value for the spinner (0..4) 054 */ 055 public PhaseScoreField(final String labelText, final int initial) { 056 super(new GridBagLayout()); 057 this.labelArea = new JTextArea(labelText); 058 labelArea.setLineWrap(true); 059 labelArea.setWrapStyleWord(true); 060 labelArea.setEditable(false); 061 labelArea.setOpaque(false); 062 labelArea.setFocusable(false); 063 // Use explicit font so the appearance doesn't change when switching LAFs 064 Font labelFont = DEFAULT_LABEL_FONT; 065 labelArea.setFont(labelFont); 066 // Constrain width to the configured global label width so it doesn't expand. 067 // Pages set GLOBAL_LABEL_WIDTH_PX to (maxLabelPx + 50). We render the label 068 // area at (GLOBAL - 50) and insert a 50px spacer so the spinner sits 069 // exactly 50px after the longest label text. 070 int prefHeight = computePreferredHeight(labelFont, 2); 071 int labelWidth = Math.max(40, globalLabelWidthPx - 50); 072 java.awt.Dimension fixed = new java.awt.Dimension(labelWidth, prefHeight); 073 // Wrap the JTextArea in a small container to guarantee horizontal size 074 this.labelWrap = new JPanel(new java.awt.BorderLayout()); 075 this.labelWrap.setPreferredSize(fixed); 076 this.labelWrap.setMinimumSize(fixed); 077 this.labelWrap.setMaximumSize(new java.awt.Dimension(labelWidth, Short.MAX_VALUE)); 078 this.labelWrap.add(labelArea, java.awt.BorderLayout.CENTER); 079 080 this.spinner = new JSpinner(new SpinnerNumberModel(initial, 0, 4, 1)); 081 JComponent editor = spinner.getEditor(); 082 // Set explicit font for spinner editor to keep sizing consistent across themes 083 Font spinnerFont = DEFAULT_LABEL_FONT; 084 editor.setFont(spinnerFont); 085 // The editor is typically a JSpinner.DefaultEditor containing a JTextField 086 try { 087 java.lang.reflect.Field f = editor.getClass().getDeclaredField("textField"); 088 f.setAccessible(true); 089 Object tf = f.get(editor); 090 if (tf instanceof javax.swing.JTextField tfField) { 091 tfField.setFont(spinnerFont); 092 } 093 } catch (ReflectiveOperationException roe) { 094 // Field may not exist on some LAF/editor implementations. Log this at most once 095 // to avoid excessive noise during automated test runs. 096 if (FONT_ADJUST_LOGGED.compareAndSet(false, true)) { 097 LOG.trace("Could not adjust spinner editor textField font (field missing or inaccessible)"); 098 } 099 } 100 int spinnerHeight = Math.max(36, computePreferredHeight(spinnerFont, 1)); 101 editor.setPreferredSize(new Dimension(128, spinnerHeight)); 102 spinner.setPreferredSize(new Dimension(128, spinnerHeight + 4)); 103 104 setBorder(BorderFactory.createEmptyBorder(0, 20, 0, 0)); // left inset 20px 105 106 GridBagConstraints gbc = new GridBagConstraints(); 107 // Label: fixed preferred width, do not expand horizontally 108 gbc.gridx = 0; 109 gbc.gridy = 0; 110 gbc.anchor = GridBagConstraints.NORTHWEST; 111 gbc.fill = GridBagConstraints.NONE; // keep label at preferred size 112 gbc.weightx = 0.0; 113 gbc.insets = new Insets(2, 2, 2, 8); 114 add(labelWrap, gbc); 115 116 // Spacer: compute width so the spinner ends up 50px after the rendered label text 117 gbc.gridx = 1; 118 gbc.gridy = 0; 119 gbc.anchor = GridBagConstraints.NORTHWEST; 120 gbc.fill = GridBagConstraints.NONE; 121 gbc.weightx = 0.0; 122 // Compute rendered text pixel width for this label (safe to call here) 123 int textPx = computeMaxLabelPixelWidth(labelFont, new String[] { labelText }); 124 int paddingWithinWrap = Math.max(0, labelWidth - textPx); 125 int spacerWidth = Math.max(0, 50 - paddingWithinWrap); 126 this.spacer = new JPanel(); this.spacer.setPreferredSize(new java.awt.Dimension(spacerWidth, 1)); 127 add(this.spacer, gbc); 128 129 // Spinner sits immediately to the right of the spacer 130 gbc.gridx = 2; 131 gbc.gridy = 0; 132 gbc.anchor = GridBagConstraints.NORTHWEST; 133 gbc.fill = GridBagConstraints.NONE; 134 gbc.weightx = 0.0; 135 add(spinner, gbc); 136 137 // Filler: consumes remaining horizontal space so the spinner doesn't get pushed to the far right 138 gbc.gridx = 3; 139 gbc.gridy = 0; 140 gbc.anchor = GridBagConstraints.NORTHWEST; 141 gbc.fill = GridBagConstraints.HORIZONTAL; 142 gbc.weightx = 1.0; 143 add(new JPanel(), gbc); 144 145 // After layout, adjust spacer so the visible gap between label and spinner is exactly 50px 146 javax.swing.SwingUtilities.invokeLater(() -> { 147 int labelRight = labelWrap.getX() + labelWrap.getWidth(); 148 int actualGap = spinner.getX() - labelRight; 149 int desiredGap = 50; 150 int currentSpacer = this.spacer.getPreferredSize().width; 151 int delta = desiredGap - actualGap; 152 if (delta != 0) { 153 int newWidth = Math.max(0, currentSpacer + delta); 154 this.spacer.setPreferredSize(new java.awt.Dimension(newWidth, 1)); 155 this.spacer.revalidate(); 156 this.revalidate(); 157 this.repaint(); 158 } 159 }); 160 } 161 162 /** 163 * Set a global label width used by all PhaseScoreField instances created 164 * after calling this method. This helps align the spinner input across 165 * multiple rows so the entry fields start at a consistent position. 166 * 167 * @param px target global label width in pixels (will be clamped to a sensible minimum) 168 */ 169 public static void setGlobalLabelWidth(final int px) { 170 globalLabelWidthPx = Math.max(80, px); 171 } 172 173 private static int computePreferredHeight(final Font font, final int approxLines) { 174 if (font == null) { 175 return 40; 176 } 177 javax.swing.JLabel probe = new javax.swing.JLabel(); 178 java.awt.FontMetrics fm = probe.getFontMetrics(font); 179 int h = fm.getHeight() * Math.max(1, approxLines) + 6; 180 return Math.max(40, h); 181 } 182 183 /** 184 * Return the configured global label width in pixels used by new instances. 185 * 186 * @return global label width in pixels 187 */ 188 public static int getGlobalLabelWidth() { return globalLabelWidthPx; } 189 190 /** 191 * Compute the pixel width of the longest label string using the given 192 * font. Returns the maximum string width in pixels. 193 * 194 * @param font font to use when measuring (may be null to use default) 195 * @param labels array of label texts to measure 196 * @return maximum string width in pixels (>=0) 197 */ 198 public static int computeMaxLabelPixelWidth(final java.awt.Font font, final String[] labels) { 199 if (labels == null || labels.length == 0) { 200 return globalLabelWidthPx; 201 } 202 javax.swing.JLabel probe = new javax.swing.JLabel(); 203 java.awt.FontMetrics fm = probe.getFontMetrics(font != null ? font : DEFAULT_LABEL_FONT); 204 int max = 0; 205 for (String s : labels) { 206 if (s != null) { 207 max = Math.max(max, fm.stringWidth(s)); 208 } 209 } 210 return max; 211 } 212 213 /** 214 * Set the visible label text for this row. 215 * 216 * @param text new label text 217 */ 218 public void setLabel(final String text) { labelArea.setText(text); } 219 220 /** 221 * Get the current label text for this field. 222 * 223 * @return label text 224 */ 225 public String getLabel() { return labelArea.getText(); } 226 227 /** 228 * Get the integer value currently selected in the spinner. 229 * 230 * @return spinner integer value (0..4) 231 */ 232 public int getValue() { 233 // If the user is mid-edit in the spinner's text field, try to commit the edit 234 try { 235 java.awt.Component ed = spinner.getEditor(); 236 if (ed instanceof javax.swing.JSpinner.DefaultEditor editorComp) { 237 javax.swing.JFormattedTextField tf = editorComp.getTextField(); 238 try { tf.commitEdit(); } catch (java.text.ParseException pe) { LOG.trace("Spinner editor parse error", pe); } 239 } 240 } catch (IllegalArgumentException | IllegalStateException re) { LOG.trace("Unexpected error committing spinner edit", re); } 241 return (Integer) spinner.getValue(); 242 } 243 244 /** 245 * Set the spinner value clamped to the valid range (0..4). 246 * 247 * @param v desired spinner value 248 */ 249 public void setValue(final int v) { spinner.setValue(Math.max(0, Math.min(4, v))); } 250 251 @Override 252 /** 253 * Set the Swing component name for this field and propagate the same name 254 * to the internal spinner so automated UI tests and accessibility tools 255 * can locate both elements reliably. 256 * 257 * @param name component name to set for this row and its spinner 258 */ 259 260 public void setName(final String name) { 261 super.setName(name); 262 spinner.setName(name); 263 } 264 265 // Diagnostics: expose spinner X and label wrap width (useful to verify layout) 266 /** 267 * Get the X coordinate of the spinner inside this component (pixels). 268 * 269 * @return spinner X position in pixels 270 */ 271 public int getSpinnerX() { return spinner.getLocation().x; } 272 273 /** 274 * Return the configured label wrap container's current width in pixels. 275 * 276 * @return label wrap width in pixels 277 */ 278 public int getLabelWrapWidth() { return labelWrap.getWidth(); } 279 280 /** 281 * Actual horizontal gap in pixels between the label wrap right edge and the spinner left edge. 282 * 283 * @return pixel gap between label and spinner 284 */ 285 public int getActualGap() { 286 int labelRight = labelWrap.getX() + labelWrap.getWidth(); 287 return spinner.getX() - labelRight; 288 } 289}