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}