diff --git a/diylc/diylc-core/src/main/java/org/diylc/common/IPlugInPort.java b/diylc/diylc-core/src/main/java/org/diylc/common/IPlugInPort.java index 3118b19fa..05094900d 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/common/IPlugInPort.java +++ b/diylc/diylc-core/src/main/java/org/diylc/common/IPlugInPort.java @@ -90,6 +90,7 @@ public interface IPlugInPort extends ISelectionProcessor, IMouseProcessor, IKeyP public static final String FONT_SCALE_FACTOR_KEY = "fontScaleFactor"; public static final String SHOW_RESIZE_DIMENSIONS_TOOLTIP_KEY = "showResizeDimensionsTooltip"; public static final String AUTO_UNIT_CONVERSION_KEY = "autoUnitConversion"; + public static final String VALUE_NOTATION_KEY = "valueNotation"; public static final String RULER_IN_SUBDIVISION_2 = "base of 2"; @@ -101,6 +102,10 @@ public interface IPlugInPort extends ISelectionProcessor, IMouseProcessor, IKeyP public static final String SNAP_TO_COMPONENTS = "Components"; public static final String SNAP_TO_DEFAULT = SNAP_TO_GRID; + public static final String VALUE_NOTATION_DECIMAL = "Decimal (4.7K, 0.22uF)"; + public static final String VALUE_NOTATION_RKM = "RKM (4K7, 0u22)"; + public static final String VALUE_NOTATION_DEFAULT = VALUE_NOTATION_DECIMAL; + public static final String SHOW_NODE_NAME_TOOLTIPS_KEY = "showNodeNameTooltips"; public static final String LOCKED_ALPHA = "lockedAlpha"; diff --git a/diylc/diylc-core/src/main/java/org/diylc/core/measures/Capacitance.java b/diylc/diylc-core/src/main/java/org/diylc/core/measures/Capacitance.java index 10dac7855..45e711518 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/core/measures/Capacitance.java +++ b/diylc/diylc-core/src/main/java/org/diylc/core/measures/Capacitance.java @@ -41,14 +41,16 @@ public class Capacitance extends AbstractMeasure { } public static Capacitance parseCapacitance(String value) { - value = value.replace("*", ""); - for (CapacitanceUnit unit : CapacitanceUnit.values()) { - if (value.toLowerCase().endsWith(unit.toString().toLowerCase())) { - value = value.substring(0, value.length() - unit.toString().length()).trim(); - return new Capacitance(parse(value), unit); - } + ValueNotation.ParsedMeasure parsed; + try { + parsed = ValueNotation.parse(value, CapacitanceUnit::fromSymbol); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Could not parse capacitance: " + value, e); } - throw new IllegalArgumentException("Could not parse capacitance: " + value); + if (parsed.unit() == null) { + throw new IllegalArgumentException("Could not parse capacitance: " + value); + } + return new Capacitance(parsed.value(), parsed.unit()); } public Double getNormalizedValue() { @@ -56,4 +58,12 @@ public class Capacitance extends AbstractMeasure { return null; return value * unit.getFactor(); } + + @Override + public String toString() { + if (ValueNotation.getCurrent() == ValueNotation.RKM && value != null && unit != null) { + return ValueNotation.formatRkm(value, unit.getRkmSymbol()); + } + return super.toString(); + } } diff --git a/diylc/diylc-core/src/main/java/org/diylc/core/measures/CapacitanceUnit.java b/diylc/diylc-core/src/main/java/org/diylc/core/measures/CapacitanceUnit.java index 155cf9c20..baf305999 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/core/measures/CapacitanceUnit.java +++ b/diylc/diylc-core/src/main/java/org/diylc/core/measures/CapacitanceUnit.java @@ -23,14 +23,16 @@ package org.diylc.core.measures; public enum CapacitanceUnit implements Unit { - pF(1e-6, "pF"), nF(1e-3, "nF"), uF(1, "uF"), mF(1e3, "mF"), F(1e6, "F"); + pF(1e-6, "pF", "p"), nF(1e-3, "nF", "n"), uF(1, "uF", "u"), mF(1e3, "mF", "m"), F(1e6, "F", "F"); double factor; String display; + String rkmSymbol; - private CapacitanceUnit(double factor, String display) { + private CapacitanceUnit(double factor, String display, String rkmSymbol) { this.factor = factor; this.display = display; + this.rkmSymbol = rkmSymbol; } @Override @@ -38,8 +40,49 @@ public enum CapacitanceUnit implements Unit { return factor; } + public String getRkmSymbol() { + return rkmSymbol; + } + @Override public String toString() { return display; } + + /** + * Resolves the unit written after a capacitance value, e.g. "nF", "n", "u", "\u00B5F" or "F". + * Case does not matter when the trailing "F" is present, which is how values have always been + * parsed. Without it, "M" is rejected so that it is never mistaken for mega. + * + * @param symbol unit text with no surrounding whitespace + * @return the unit, or null when the text is not a capacitance unit + */ + public static CapacitanceUnit fromSymbol(String symbol) { + if (symbol.equalsIgnoreCase("F")) { + return F; + } + String prefix = symbol; + if (symbol.length() == 2 && (symbol.endsWith("F") || symbol.endsWith("f"))) { + prefix = symbol.substring(0, 1).toLowerCase(); + } else if (symbol.equals("M")) { + return null; + } + switch (prefix) { + case "p": + case "P": + return pF; + case "n": + case "N": + return nF; + case "u": + case "U": + case "\u00B5": + case "\u03BC": + return uF; + case "m": + return mF; + default: + return null; + } + } } diff --git a/diylc/diylc-core/src/main/java/org/diylc/core/measures/Resistance.java b/diylc/diylc-core/src/main/java/org/diylc/core/measures/Resistance.java index 2f5ec9a01..056245310 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/core/measures/Resistance.java +++ b/diylc/diylc-core/src/main/java/org/diylc/core/measures/Resistance.java @@ -109,14 +109,16 @@ public class Resistance extends AbstractMeasure { } public static Resistance parseResistance(String value) { - value = value.replace("*", "").replace("R", "\u2126"); - for (ResistanceUnit unit : ResistanceUnit.values()) { - if (value.toLowerCase().endsWith(unit.toString().toLowerCase())) { - value = value.substring(0, value.length() - unit.toString().length()).trim(); - return new Resistance(parse(value), unit); - } + ValueNotation.ParsedMeasure parsed; + try { + parsed = ValueNotation.parse(value, ResistanceUnit::fromSymbol); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Could not parse resistance: " + value, e); } - throw new IllegalArgumentException("Could not parse resistance: " + value); + if (parsed.unit() == null) { + throw new IllegalArgumentException("Could not parse resistance: " + value); + } + return new Resistance(parsed.value(), parsed.unit()); } public Double getNormalizedValue() { @@ -124,4 +126,12 @@ public class Resistance extends AbstractMeasure { return null; return value * unit.getFactor(); } + + @Override + public String toString() { + if (ValueNotation.getCurrent() == ValueNotation.RKM && value != null && unit != null) { + return ValueNotation.formatRkm(value, unit.getRkmSymbol()); + } + return super.toString(); + } } diff --git a/diylc/diylc-core/src/main/java/org/diylc/core/measures/ResistanceUnit.java b/diylc/diylc-core/src/main/java/org/diylc/core/measures/ResistanceUnit.java index 08c8cc691..313cbdd77 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/core/measures/ResistanceUnit.java +++ b/diylc/diylc-core/src/main/java/org/diylc/core/measures/ResistanceUnit.java @@ -23,14 +23,16 @@ package org.diylc.core.measures; public enum ResistanceUnit implements Unit { - R(1, "\u03A9"), K(1e3, "K"), M(1e6, "M"); + R(1, "\u03A9", "R"), K(1e3, "K", "K"), M(1e6, "M", "M"); double factor; String display; + String rkmSymbol; - private ResistanceUnit(double factor, String display) { + private ResistanceUnit(double factor, String display, String rkmSymbol) { this.factor = factor; this.display = display; + this.rkmSymbol = rkmSymbol; } @Override @@ -39,8 +41,45 @@ public enum ResistanceUnit implements Unit { } + public String getRkmSymbol() { + return rkmSymbol; + } + @Override public String toString() { return display; } + + /** + * Resolves the unit written after a resistance value, e.g. "K", "kohm", "R", "\u03A9" or "Meg". + * A lower-case "m" means mega, because there is no milliohm unit and parts lists commonly write + * "1m" for one megohm. + * + * @param symbol unit text with no surrounding whitespace + * @return the unit, or null when the text is not a resistance unit + */ + public static ResistanceUnit fromSymbol(String symbol) { + String prefix = symbol; + String lower = symbol.toLowerCase(); + for (String ohm : new String[] {"ohms", "ohm", "\u03A9", "\u2126"}) { + if (lower.endsWith(ohm.toLowerCase())) { + prefix = symbol.substring(0, symbol.length() - ohm.length()); + if (prefix.isEmpty()) { + return R; + } + break; + } + } + switch (prefix.toLowerCase()) { + case "r": + return R; + case "k": + return K; + case "m": + case "meg": + return M; + default: + return null; + } + } } diff --git a/diylc/diylc-core/src/main/java/org/diylc/core/measures/ValueNotation.java b/diylc/diylc-core/src/main/java/org/diylc/core/measures/ValueNotation.java new file mode 100644 index 000000000..2ac450f07 --- /dev/null +++ b/diylc/diylc-core/src/main/java/org/diylc/core/measures/ValueNotation.java @@ -0,0 +1,128 @@ +/* + + DIY Layout Creator (DIYLC). + Copyright (c) 2009-2025 held jointly by the individual authors. + + This file is part of DIYLC. + + DIYLC is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + DIYLC is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DIYLC. If not, see . + +*/ +package org.diylc.core.measures; + +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.Locale; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Notation used to write resistance and capacitance values: plain decimal ("4.7K", "0.22uF") or + * RKM as defined by IEC 60062, where the unit symbol takes the place of the decimal point ("4K7", + * "0u22"). Parsing accepts both regardless of the selected notation. + * + * @author Branislav Stojkovic + */ +public enum ValueNotation { + + DECIMAL, RKM; + + private static final DecimalFormat RKM_FORMAT = + new DecimalFormat("0.####", DecimalFormatSymbols.getInstance(Locale.US)); + + private static final Pattern RKM_PATTERN = Pattern.compile("^(-?)(\\d*)([^\\d.\\-]+)(\\d+)([^\\d.]*)$"); + + // read on every repaint through toString(), so it is kept here rather than looked up in the + // configuration + private static volatile ValueNotation current = DECIMAL; + + public static ValueNotation getCurrent() { + return current; + } + + public static void setCurrent(ValueNotation notation) { + current = notation == null ? DECIMAL : notation; + } + + /** + * Writes a value in RKM notation, e.g. 4.7 with "K" becomes "4K7" and 100 with "R" becomes + * "100R". The value is kept in the unit it is stored in and never renormalized. + * + * @param value value expressed in the unit + * @param rkmSymbol symbol that stands in for the decimal point + * @return RKM code + */ + public static String formatRkm(double value, String rkmSymbol) { + String text; + synchronized (RKM_FORMAT) { + text = RKM_FORMAT.format(value); + } + int dot = text.indexOf('.'); + if (dot < 0) { + return text + rkmSymbol; + } + return text.substring(0, dot) + rkmSymbol + text.substring(dot + 1); + } + + /** + * Parses a value written either in decimal notation with an optional unit ("4.7", "4.7k", + * "0.22 uF") or in RKM notation ("4K7", "R22", "2n2F"). + * + * @param text text to parse + * @param unitResolver maps the unit text to a unit, returning null when it is not recognized + * @return parsed value; its unit is null when the text carries no unit + * @throws IllegalArgumentException when the text cannot be parsed + */ + public static ParsedMeasure parse(String text, + Function unitResolver) { + if (text == null) { + throw new IllegalArgumentException("Nothing to parse"); + } + String value = text.replace("*", "").replace(",", ".").replaceAll("\\s", ""); + if (value.isEmpty()) { + throw new IllegalArgumentException("Nothing to parse"); + } + + int unitStart = value.length(); + while (unitStart > 0 && !Character.isDigit(value.charAt(unitStart - 1)) + && value.charAt(unitStart - 1) != '.') { + unitStart--; + } + String number = value.substring(0, unitStart); + String unitText = value.substring(unitStart); + U unit = unitText.isEmpty() ? null : unitResolver.apply(unitText); + if (unitText.isEmpty() || unit != null) { + try { + return new ParsedMeasure(Double.parseDouble(number), unit); + } catch (NumberFormatException e) { + // not a decimal number, try RKM below + } + } + + Matcher matcher = RKM_PATTERN.matcher(value); + if (matcher.matches()) { + unit = unitResolver.apply(matcher.group(3) + matcher.group(5)); + if (unit != null) { + String integerPart = matcher.group(2).isEmpty() ? "0" : matcher.group(2); + double parsed = Double.parseDouble(integerPart + "." + matcher.group(4)); + return new ParsedMeasure(matcher.group(1).isEmpty() ? parsed : -parsed, unit); + } + } + throw new IllegalArgumentException("Could not parse value: " + text); + } + + public record ParsedMeasure(double value, U unit) { + } +} diff --git a/diylc/diylc-core/src/main/java/org/diylc/presenter/ValueNotationBinder.java b/diylc/diylc-core/src/main/java/org/diylc/presenter/ValueNotationBinder.java new file mode 100644 index 000000000..99514f9bb --- /dev/null +++ b/diylc/diylc-core/src/main/java/org/diylc/presenter/ValueNotationBinder.java @@ -0,0 +1,54 @@ +/* + + DIY Layout Creator (DIYLC). + Copyright (c) 2009-2025 held jointly by the individual authors. + + This file is part of DIYLC. + + DIYLC is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + DIYLC is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DIYLC. If not, see . + +*/ +package org.diylc.presenter; + +import org.diylc.appframework.miscutils.IConfigurationManager; + +import org.diylc.common.IPlugInPort; +import org.diylc.core.measures.ValueNotation; + +/** + * Keeps {@link ValueNotation#getCurrent()} in sync with the value notation the user picked in the + * configuration, and redraws the project when it changes. The notation is global, so only the + * main window binds it; secondary presenters (previews, thumbnails, batch conversion, regression + * runs) run on in-memory configurations and must not reset it. + * + * @author Branislav Stojkovic + */ +public class ValueNotationBinder { + + public static void bind(IConfigurationManager configManager, Runnable refresh) { + apply(configManager.readString(IPlugInPort.VALUE_NOTATION_KEY, + IPlugInPort.VALUE_NOTATION_DEFAULT)); + configManager.addConfigListener(IPlugInPort.VALUE_NOTATION_KEY, (key, value) -> { + apply(value == null ? null : value.toString()); + // cached component images still carry the labels drawn in the previous notation + DrawingCache.Instance.clear(); + refresh.run(); + }); + } + + static void apply(String configValue) { + ValueNotation.setCurrent(IPlugInPort.VALUE_NOTATION_RKM.equalsIgnoreCase(configValue) + ? ValueNotation.RKM : ValueNotation.DECIMAL); + } +} diff --git a/diylc/diylc-core/src/main/resources/update.xml b/diylc/diylc-core/src/main/resources/update.xml index 37e1ef7b2..316280ad4 100644 --- a/diylc/diylc-core/src/main/resources/update.xml +++ b/diylc/diylc-core/src/main/resources/update.xml @@ -629,6 +629,10 @@ 2026-11-07 00:00:00.000 CET + + NEW_FEATURE + Added 'Value Notation' to the Config menu to show resistance and capacitance values in RKM notation, e.g. 4K7, 0R22, 2n2 or 0u22, instead of decimal; values can be typed in either notation, and typing the unit together with the value, e.g. 4K7 or 2.2nF, sets the unit at the same time + BUG_FIX Rotated labels in pasted components and building blocks were drawn in the wrong place and at the wrong angle while following the mouse, until the components were placed diff --git a/diylc/diylc-core/src/test/java/org/diylc/core/measures/ValueNotationTest.java b/diylc/diylc-core/src/test/java/org/diylc/core/measures/ValueNotationTest.java new file mode 100644 index 000000000..67d7fc638 --- /dev/null +++ b/diylc/diylc-core/src/test/java/org/diylc/core/measures/ValueNotationTest.java @@ -0,0 +1,203 @@ +/* + + DIY Layout Creator (DIYLC). + Copyright (c) 2009-2025 held jointly by the individual authors. + + This file is part of DIYLC. + + DIYLC is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + DIYLC is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DIYLC. If not, see . + +*/ +package org.diylc.core.measures; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; + +import org.junit.After; +import org.junit.Test; + +public class ValueNotationTest { + + private static final double DELTA = 1e-9; + + @After + public void resetNotation() { + ValueNotation.setCurrent(ValueNotation.DECIMAL); + } + + // --- formatting --- + + @Test + public void decimalResistanceIsUnchanged() { + assertEquals("4.7\u03A9", new Resistance(4.7, ResistanceUnit.R).toString()); + assertEquals("0.22\u03A9", new Resistance(0.22, ResistanceUnit.R).toString()); + assertEquals("4.7K", new Resistance(4.7, ResistanceUnit.K).toString()); + assertEquals("1M", new Resistance(1d, ResistanceUnit.M).toString()); + } + + @Test + public void decimalCapacitanceIsUnchanged() { + assertEquals("0.22uF", new Capacitance(0.22, CapacitanceUnit.uF).toString()); + assertEquals("100nF", new Capacitance(100d, CapacitanceUnit.nF).toString()); + } + + @Test + public void rkmResistance() { + ValueNotation.setCurrent(ValueNotation.RKM); + assertEquals("4R7", new Resistance(4.7, ResistanceUnit.R).toString()); + assertEquals("0R22", new Resistance(0.22, ResistanceUnit.R).toString()); + assertEquals("100R", new Resistance(100d, ResistanceUnit.R).toString()); + assertEquals("4K7", new Resistance(4.7, ResistanceUnit.K).toString()); + assertEquals("47K", new Resistance(47d, ResistanceUnit.K).toString()); + assertEquals("1M", new Resistance(1d, ResistanceUnit.M).toString()); + assertEquals("4700R", new Resistance(4700d, ResistanceUnit.R).toString()); + assertEquals("0R", new Resistance(0d, ResistanceUnit.R).toString()); + assertEquals("4K7501", new Resistance(4.75012, ResistanceUnit.K).toString()); + } + + @Test + public void rkmCapacitance() { + ValueNotation.setCurrent(ValueNotation.RKM); + assertEquals("0u22", new Capacitance(0.22, CapacitanceUnit.uF).toString()); + assertEquals("2n2", new Capacitance(2.2, CapacitanceUnit.nF).toString()); + assertEquals("100n", new Capacitance(100d, CapacitanceUnit.nF).toString()); + assertEquals("4p7", new Capacitance(4.7, CapacitanceUnit.pF).toString()); + assertEquals("1000u", new Capacitance(1000d, CapacitanceUnit.uF).toString()); + } + + // --- parsing --- + + @Test + public void parsesDecimalResistance() { + assertResistance(4.7, ResistanceUnit.K, "4.7K"); + assertResistance(4.7, ResistanceUnit.K, "4.7k"); + assertResistance(4.7, ResistanceUnit.K, "4.7 K"); + assertResistance(4.7, ResistanceUnit.K, "4,7K"); + assertResistance(4.7, ResistanceUnit.K, "4.7kohm"); + assertResistance(4.7, ResistanceUnit.R, "4.7ohm"); + assertResistance(4.7, ResistanceUnit.R, "4.7 ohms"); + assertResistance(4.7, ResistanceUnit.R, "4.7\u03A9"); + assertResistance(4.7, ResistanceUnit.R, "4.7\u2126"); + assertResistance(4.7, ResistanceUnit.K, "4.7K\u03A9"); + assertResistance(1, ResistanceUnit.M, "1M"); + assertResistance(1, ResistanceUnit.M, "1m"); + assertResistance(2.2, ResistanceUnit.M, "2.2Meg"); + assertResistance(100, ResistanceUnit.R, "100R"); + assertResistance(100, ResistanceUnit.R, "100r"); + assertResistance(0.5, ResistanceUnit.K, ".5K"); + assertResistance(10, ResistanceUnit.K, "10K*"); + } + + @Test + public void parsesRkmResistance() { + assertResistance(4.7, ResistanceUnit.R, "4R7"); + assertResistance(0.22, ResistanceUnit.R, "0R22"); + assertResistance(0.22, ResistanceUnit.R, "R22"); + assertResistance(4.7, ResistanceUnit.K, "4K7"); + assertResistance(4.7, ResistanceUnit.K, "4k7"); + assertResistance(4.75, ResistanceUnit.K, "4K75"); + assertResistance(2.2, ResistanceUnit.M, "2M2"); + } + + @Test + public void parsesDecimalCapacitance() { + assertCapacitance(0.22, CapacitanceUnit.uF, "0.22uF"); + assertCapacitance(0.22, CapacitanceUnit.uF, "0.22UF"); + assertCapacitance(0.22, CapacitanceUnit.uF, "0.22u"); + assertCapacitance(220, CapacitanceUnit.nF, "220 nF"); + assertCapacitance(220, CapacitanceUnit.nF, "220n"); + assertCapacitance(47, CapacitanceUnit.pF, "47pf"); + assertCapacitance(4.7, CapacitanceUnit.uF, "4.7\u00B5F"); + assertCapacitance(1, CapacitanceUnit.F, "1F"); + assertCapacitance(1, CapacitanceUnit.mF, "1mF"); + assertCapacitance(1, CapacitanceUnit.mF, "1MF"); + assertCapacitance(1, CapacitanceUnit.mF, "1m"); + } + + @Test + public void parsesRkmCapacitance() { + assertCapacitance(0.22, CapacitanceUnit.uF, "0u22"); + assertCapacitance(0.22, CapacitanceUnit.uF, "u22"); + assertCapacitance(2.2, CapacitanceUnit.nF, "2n2"); + assertCapacitance(2.2, CapacitanceUnit.nF, "2n2F"); + assertCapacitance(4.7, CapacitanceUnit.uF, "4u7"); + assertCapacitance(4.7, CapacitanceUnit.uF, "4\u00B57"); + assertCapacitance(4.7, CapacitanceUnit.uF, "4\u03BC7"); + assertCapacitance(4.7, CapacitanceUnit.pF, "4p7"); + } + + @Test + public void bareNumberHasNoUnit() { + ValueNotation.ParsedMeasure parsed = + ValueNotation.parse("4.7", ResistanceUnit::fromSymbol); + assertEquals(4.7, parsed.value(), DELTA); + assertNull(parsed.unit()); + } + + @Test + public void rejectsInvalidText() { + assertRejected("4u7", ResistanceUnit.class); + assertRejected("4K7K", ResistanceUnit.class); + assertRejected("K", ResistanceUnit.class); + assertRejected("", ResistanceUnit.class); + assertRejected("100", ResistanceUnit.class); + assertRejected("4R7", CapacitanceUnit.class); + assertRejected("1M", CapacitanceUnit.class); + assertRejected("4M7", CapacitanceUnit.class); + } + + @Test + public void roundTripsInBothNotations() { + double[] values = {0.22, 1, 4.7, 47, 100, 4700, 0.0047}; + for (ValueNotation notation : ValueNotation.values()) { + ValueNotation.setCurrent(notation); + for (double value : values) { + for (ResistanceUnit unit : ResistanceUnit.values()) { + Resistance resistance = new Resistance(value, unit); + assertEquals(resistance, Resistance.parseResistance(resistance.toString())); + } + for (CapacitanceUnit unit : CapacitanceUnit.values()) { + Capacitance capacitance = new Capacitance(value, unit); + assertEquals(capacitance, Capacitance.parseCapacitance(capacitance.toString())); + } + } + } + } + + private static void assertResistance(double value, ResistanceUnit unit, String text) { + Resistance parsed = Resistance.parseResistance(text); + assertEquals(text, value, parsed.getValue(), DELTA); + assertEquals(text, unit, parsed.getUnit()); + } + + private static void assertCapacitance(double value, CapacitanceUnit unit, String text) { + Capacitance parsed = Capacitance.parseCapacitance(text); + assertEquals(text, value, parsed.getValue(), DELTA); + assertEquals(text, unit, parsed.getUnit()); + } + + private static void assertRejected(String text, Class unitType) { + try { + if (unitType == ResistanceUnit.class) { + Resistance.parseResistance(text); + } else { + Capacitance.parseCapacitance(text); + } + fail("Expected " + text + " to be rejected"); + } catch (IllegalArgumentException e) { + // expected + } + } +} diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/gui/editor/MeasureArrayEditor.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/gui/editor/MeasureArrayEditor.java index db6a31f07..f55e1cdca 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/gui/editor/MeasureArrayEditor.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/gui/editor/MeasureArrayEditor.java @@ -26,6 +26,8 @@ import java.awt.Color; import java.awt.Container; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; +import java.awt.event.FocusAdapter; +import java.awt.event.FocusEvent; import java.awt.event.KeyListener; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; @@ -34,12 +36,19 @@ import java.lang.reflect.Constructor; import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; import javax.swing.JComboBox; +import javax.swing.JTextField; import org.apache.log4j.Logger; import org.diylc.swingframework.DoubleArrayTextField; import org.diylc.common.PropertyWrapper; import org.diylc.core.measures.AbstractMeasure; +import org.diylc.core.measures.Unit; +import org.diylc.core.measures.ValueNotation; +import org.diylc.core.measures.ValueNotation.ParsedMeasure; import org.diylc.utils.Constants; public class MeasureArrayEditor extends Container { @@ -50,52 +59,59 @@ public class MeasureArrayEditor extends Container { .getLogger(MeasureArrayEditor.class); private Color oldBg; - private DoubleArrayTextField valueField; + private JTextField valueField; private JComboBox unitBox; + private boolean ignoreUnitChanges = false; + @SuppressWarnings("unchecked") public MeasureArrayEditor(final PropertyWrapper property) { setLayout(new BorderLayout()); final AbstractMeasure[] measure = ((AbstractMeasure[]) property .getValue()); - Double[] values = new Double[measure.length]; - for (int i = 0; i < measure.length; i++) { - values[i] = measure[i] == null ? null : measure[i].getValue(); - } - valueField = new DoubleArrayTextField(measure == null ? null : values); - if (property.isReadOnly()) - valueField.setEnabled(false); - oldBg = valueField.getBackground(); - valueField.addPropertyChangeListener( - DoubleArrayTextField.VALUE_PROPERTY, - new PropertyChangeListener() { + final Function unitResolver = MeasureEditor + .getUnitResolver(property.getType().getComponentType()); + if (unitResolver == null) { + valueField = createDoubleField(property, measure); + } else { + final MeasureTextField>> measureField = new MeasureTextField>>( + formatForField(measure), text -> parseSections(text, unitResolver)); + measureField.addPropertyChangeListener(MeasureTextField.VALUE_PROPERTY, + new PropertyChangeListener() { - @Override - public void propertyChange(PropertyChangeEvent evt) { - try { - Constructor ctor = property.getType().getComponentType() - .getConstructors()[0]; - Double[] newValues = (Double[]) evt.getNewValue(); - AbstractMeasure[] newMeasure; - if (newValues == null) - newMeasure = null; - else { - newMeasure = (AbstractMeasure[]) Array.newInstance(property.getType().getComponentType(), newValues.length); - for (int i = 0; i < newValues.length; i++) { - newMeasure[i] = (AbstractMeasure) ctor - .newInstance(newValues[i], - unitBox.getSelectedItem()); + @Override + public void propertyChange(PropertyChangeEvent evt) { + List> parsed = (List>) evt + .getNewValue(); + // the unit box shows the unit of the first section + if (parsed != null && !parsed.isEmpty() && parsed.get(0) != null + && parsed.get(0).unit() != null + && parsed.get(0).unit() != unitBox.getSelectedItem()) { + ignoreUnitChanges = true; + try { + unitBox.setSelectedItem(parsed.get(0).unit()); + } finally { + ignoreUnitChanges = false; } } - - property.setValue(newMeasure); - property.setChanged(true); - valueField.setBackground(oldBg); - unitBox.setBackground(oldBg); - } catch (Exception e) { - LOG.error("Error while updating property value", e); + applySections(property, parsed, false); } + }); + measureField.addFocusListener(new FocusAdapter() { + + @Override + public void focusLost(FocusEvent e) { + if (measureField.getValue() != null) { + measureField.setDisplayText( + formatForField((AbstractMeasure[]) property.getValue())); } - }); + } + }); + valueField = measureField; + } + if (property.isReadOnly()) { + valueField.setEnabled(false); + } + oldBg = valueField.getBackground(); add(valueField, BorderLayout.CENTER); try { Type type = ((ParameterizedType) property.getType().getComponentType() @@ -108,29 +124,18 @@ public class MeasureArrayEditor extends Container { @Override public void actionPerformed(ActionEvent evt) { - try { - Constructor ctor = property.getType().getComponentType() - .getConstructors()[0]; - - AbstractMeasure[] newMeasure; - Double[] newValues = valueField.getValue(); - if (newValues == null) - newMeasure = null; - else { - newMeasure = (AbstractMeasure[]) Array.newInstance(property.getType().getComponentType(), newValues.length); - for (int i = 0; i < newValues.length; i++) { - newMeasure[i] = (AbstractMeasure) ctor - .newInstance(newValues[i], - unitBox.getSelectedItem()); - } + if (ignoreUnitChanges) { + return; + } + if (valueField instanceof DoubleArrayTextField doubleField) { + applyValues(property, doubleField.getValue()); + } else { + MeasureTextField>> measureField = (MeasureTextField>>) valueField; + if (measureField.getValue() != null) { + applySections(property, measureField.getValue(), true); + measureField.setDisplayText( + formatForField((AbstractMeasure[]) property.getValue())); } - - property.setValue(newMeasure); - property.setChanged(true); - valueField.setBackground(oldBg); - unitBox.setBackground(oldBg); - } catch (Exception e) { - LOG.error("Error while updating property units", e); } } }); @@ -145,6 +150,101 @@ public class MeasureArrayEditor extends Container { } } + private DoubleArrayTextField createDoubleField(final PropertyWrapper property, + AbstractMeasure[] measure) { + Double[] values = new Double[measure.length]; + for (int i = 0; i < measure.length; i++) { + values[i] = measure[i] == null ? null : measure[i].getValue(); + } + DoubleArrayTextField valueField = new DoubleArrayTextField(measure == null ? null : values); + valueField.addPropertyChangeListener( + DoubleArrayTextField.VALUE_PROPERTY, + new PropertyChangeListener() { + + @Override + public void propertyChange(PropertyChangeEvent evt) { + applyValues(property, (Double[]) evt.getNewValue()); + } + }); + return valueField; + } + + private void applyValues(PropertyWrapper property, Double[] newValues) { + try { + Constructor ctor = property.getType().getComponentType() + .getConstructors()[0]; + AbstractMeasure[] newMeasure; + if (newValues == null) + newMeasure = null; + else { + newMeasure = (AbstractMeasure[]) Array.newInstance(property.getType().getComponentType(), newValues.length); + for (int i = 0; i < newValues.length; i++) { + newMeasure[i] = (AbstractMeasure) ctor + .newInstance(newValues[i], + unitBox.getSelectedItem()); + } + } + + property.setValue(newMeasure); + property.setChanged(true); + valueField.setBackground(oldBg); + unitBox.setBackground(oldBg); + } catch (Exception e) { + LOG.error("Error while updating property value", e); + } + } + + /** + * Sections typed with their own unit keep it, the others take the unit selected in the box. + * When the box itself changed, its unit is applied to every section. + */ + private void applySections(PropertyWrapper property, List> sections, + boolean forceBoxUnit) { + try { + Constructor ctor = property.getType().getComponentType() + .getConstructors()[0]; + AbstractMeasure[] newMeasure = null; + if (sections != null) { + newMeasure = (AbstractMeasure[]) Array.newInstance(property.getType().getComponentType(), sections.size()); + for (int i = 0; i < sections.size(); i++) { + ParsedMeasure section = sections.get(i); + Object unit = section == null || section.unit() == null || forceBoxUnit + ? unitBox.getSelectedItem() : section.unit(); + newMeasure[i] = (AbstractMeasure) ctor + .newInstance(section == null ? null : section.value(), unit); + } + } + property.setValue(newMeasure); + property.setChanged(true); + valueField.setBackground(oldBg); + unitBox.setBackground(oldBg); + } catch (Exception e) { + LOG.error("Error while updating property value", e); + } + } + + private static List> parseSections(String text, + Function unitResolver) { + List> sections = new ArrayList>(); + for (String part : text.split("/", -1)) { + sections.add(part.trim().isEmpty() ? null : ValueNotation.parse(part, unitResolver)); + } + return sections; + } + + private static String formatForField(AbstractMeasure[] measure) { + if (measure == null) { + return ""; + } + Object boxUnit = measure.length == 0 || measure[0] == null ? null : measure[0].getUnit(); + List parts = new ArrayList(); + for (AbstractMeasure section : measure) { + parts.add(MeasureEditor.formatForField(section, + section == null || section.getUnit() == boxUnit)); + } + return String.join(" / ", parts); + } + @Override public void requestFocus() { this.valueField.requestFocus(); diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/gui/editor/MeasureEditor.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/gui/editor/MeasureEditor.java index 392a12a0e..4890224f9 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/gui/editor/MeasureEditor.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/gui/editor/MeasureEditor.java @@ -26,6 +26,8 @@ import java.awt.Color; import java.awt.Container; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; +import java.awt.event.FocusAdapter; +import java.awt.event.FocusEvent; import java.awt.event.KeyListener; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; @@ -33,12 +35,22 @@ import java.lang.reflect.Constructor; import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.text.DecimalFormat; +import java.text.Format; +import java.util.function.Function; import javax.swing.JComboBox; +import javax.swing.JTextField; import org.apache.log4j.Logger; import org.diylc.appframework.miscutils.ConfigurationManager; import org.diylc.common.IPlugInPort; +import org.diylc.core.measures.Capacitance; +import org.diylc.core.measures.CapacitanceUnit; +import org.diylc.core.measures.Resistance; +import org.diylc.core.measures.ResistanceUnit; import org.diylc.core.measures.Size; import org.diylc.core.measures.SizeUnit; +import org.diylc.core.measures.ValueNotation; +import org.diylc.core.measures.ValueNotation.ParsedMeasure; import org.diylc.swingframework.DoubleTextField; import org.diylc.common.PropertyWrapper; @@ -52,39 +64,70 @@ public class MeasureEditor extends Container { private final static Logger LOG = Logger.getLogger(MeasureEditor.class); + private static final Format DECIMAL_FORMAT = new DecimalFormat("0.#####"); + private Color oldBg; - private DoubleTextField valueField; + private JTextField valueField; private JComboBox unitBox; + private boolean ignoreUnitChanges = false; @SuppressWarnings("unchecked") public MeasureEditor(final PropertyWrapper property) { setLayout(new BorderLayout()); AbstractMeasure measure = ((AbstractMeasure) property.getValue()); measure = convertUnitsIfNeeded(measure); - valueField = new DoubleTextField(measure == null ? null : measure.getValue()); - oldBg = valueField.getBackground(); - valueField.addPropertyChangeListener(DoubleTextField.VALUE_PROPERTY, new PropertyChangeListener() { + final Function unitResolver = getUnitResolver(property.getType()); + if (unitResolver == null) { + final DoubleTextField doubleField = + new DoubleTextField(measure == null ? null : measure.getValue()); + doubleField.addPropertyChangeListener(DoubleTextField.VALUE_PROPERTY, + new PropertyChangeListener() { - @Override - public void propertyChange(PropertyChangeEvent evt) { - try { - Double newValue = (Double) evt.getNewValue(); - if (newValue == null) { - property.setValue(null); - } else { - Constructor ctor = property.getType().getConstructors()[0]; - AbstractMeasure newMeasure = - (AbstractMeasure) ctor.newInstance(newValue, unitBox.getSelectedItem()); - property.setValue(newMeasure); + @Override + public void propertyChange(PropertyChangeEvent evt) { + applyValue(property, (Double) evt.getNewValue(), unitBox.getSelectedItem()); + } + }); + valueField = doubleField; + } else { + final MeasureTextField> measureField = + new MeasureTextField>(formatForField(measure, true), + text -> ValueNotation.parse(text, unitResolver)); + measureField.addPropertyChangeListener(MeasureTextField.VALUE_PROPERTY, + new PropertyChangeListener() { + + @Override + public void propertyChange(PropertyChangeEvent evt) { + ParsedMeasure parsed = (ParsedMeasure) evt.getNewValue(); + if (parsed == null) { + applyValue(property, null, unitBox.getSelectedItem()); + return; + } + // a unit typed together with the value, e.g. 4K7, also switches the unit box + if (parsed.unit() != null && parsed.unit() != unitBox.getSelectedItem()) { + ignoreUnitChanges = true; + try { + unitBox.setSelectedItem(parsed.unit()); + } finally { + ignoreUnitChanges = false; + } + } + applyValue(property, parsed.value(), unitBox.getSelectedItem()); + } + }); + measureField.addFocusListener(new FocusAdapter() { + + @Override + public void focusLost(FocusEvent e) { + if (measureField.getValue() != null) { + measureField.setDisplayText( + formatForField((AbstractMeasure) property.getValue(), true)); } - property.setChanged(true); - valueField.setBackground(oldBg); - unitBox.setBackground(oldBg); - } catch (Exception e) { - LOG.error("Error while updating property value", e); } - } - }); + }); + valueField = measureField; + } + oldBg = valueField.getBackground(); add(valueField, BorderLayout.CENTER); try { Type type = ((ParameterizedType) property.getType().getGenericSuperclass()).getActualTypeArguments()[0]; @@ -95,33 +138,27 @@ public class MeasureEditor extends Container { @Override public void actionPerformed(ActionEvent evt) { + if (ignoreUnitChanges) { + return; + } try { - Double newValue = valueField.getValue(); - if (newValue == null) { - property.setValue(null); - } else { - Constructor ctor = property.getType().getConstructors()[0]; - if (property.isReadOnly()) { - double oldFactor = ((Unit)((AbstractMeasure)property.getValue()).getUnit()).getFactor(); - double newFactor = ((Unit)unitBox.getSelectedItem()).getFactor(); - newValue = newValue * oldFactor / newFactor; - valueField.setValue(newValue); - } - AbstractMeasure newMeasure = - (AbstractMeasure) ctor.newInstance(newValue, - (Enum) unitBox.getSelectedItem()); - property.setValue(newMeasure); + Double newValue = getFieldValue(); + if (newValue != null && property.isReadOnly()) { + double oldFactor = ((Unit)((AbstractMeasure)property.getValue()).getUnit()).getFactor(); + double newFactor = ((Unit)unitBox.getSelectedItem()).getFactor(); + newValue = newValue * oldFactor / newFactor; + } + applyValue(property, newValue, unitBox.getSelectedItem()); + if (newValue != null && (property.isReadOnly() || unitResolver != null)) { + setFieldValue((AbstractMeasure) property.getValue()); } - property.setChanged(true); - valueField.setBackground(oldBg); - unitBox.setBackground(oldBg); } catch (Exception e) { LOG.error("Error while updating property units", e); } } }); add(unitBox, BorderLayout.EAST); - + if (property.isReadOnly()) { valueField.setEnabled(false); unitBox.setEnabled(true); @@ -136,6 +173,70 @@ public class MeasureEditor extends Container { } } + /** + * Returns the function that resolves unit symbols typed together with the value, or null when + * the measure only accepts a plain number. + */ + static Function getUnitResolver(Class measureType) { + if (Resistance.class.equals(measureType)) { + return ResistanceUnit::fromSymbol; + } + if (Capacitance.class.equals(measureType)) { + return CapacitanceUnit::fromSymbol; + } + return null; + } + + /** + * Text shown in the value field. It is always decimal, whatever the selected value notation, + * because the unit box beside it already shows the unit; the unit is written out only when it + * differs from the one in the box. + */ + static String formatForField(AbstractMeasure measure, boolean unitShownInBox) { + if (measure == null || measure.getValue() == null) { + return ""; + } + String number; + synchronized (DECIMAL_FORMAT) { + number = DECIMAL_FORMAT.format(measure.getValue()); + } + return unitShownInBox || measure.getUnit() == null ? number : number + measure.getUnit(); + } + + private void applyValue(PropertyWrapper property, Double newValue, Object unit) { + try { + if (newValue == null) { + property.setValue(null); + } else { + Constructor ctor = property.getType().getConstructors()[0]; + property.setValue((AbstractMeasure) ctor.newInstance(newValue, unit)); + } + property.setChanged(true); + valueField.setBackground(oldBg); + unitBox.setBackground(oldBg); + } catch (Exception e) { + LOG.error("Error while updating property value", e); + } + } + + @SuppressWarnings("unchecked") + private Double getFieldValue() { + if (valueField instanceof DoubleTextField doubleField) { + return doubleField.getValue(); + } + ParsedMeasure parsed = ((MeasureTextField>) valueField).getValue(); + return parsed == null ? null : parsed.value(); + } + + @SuppressWarnings("unchecked") + private void setFieldValue(AbstractMeasure measure) { + if (valueField instanceof DoubleTextField doubleField) { + doubleField.setValue(measure == null ? null : measure.getValue()); + } else { + ((MeasureTextField>) valueField).setDisplayText(formatForField(measure, true)); + } + } + private AbstractMeasure convertUnitsIfNeeded(AbstractMeasure value) { if (ConfigurationManager.getInstance().readBoolean(IPlugInPort.AUTO_UNIT_CONVERSION_KEY, true) && value instanceof Size sizeValue) { diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/gui/editor/MeasureTextField.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/gui/editor/MeasureTextField.java new file mode 100644 index 000000000..17a8369f4 --- /dev/null +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/gui/editor/MeasureTextField.java @@ -0,0 +1,131 @@ +/* + + DIY Layout Creator (DIYLC). + Copyright (c) 2009-2025 held jointly by the individual authors. + + This file is part of DIYLC. + + DIYLC is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + DIYLC is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DIYLC. If not, see . + +*/ +package org.diylc.swing.gui.editor; + +import java.awt.BorderLayout; +import java.util.function.Function; +import javax.swing.BorderFactory; +import javax.swing.JLabel; +import javax.swing.JTextField; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +import org.diylc.utils.IconLoader; + +/** + * {@link JTextField} that parses its text on every change and fires {@link #VALUE_PROPERTY} with + * the result, showing a warning icon while the text cannot be parsed. Unlike + * {@code DoubleTextField} the text is never rewritten while the user types; call + * {@link #setDisplayText(String)} to reformat it. + * + * @author Branislav Stojkovic + * + * @param type of the parsed value + */ +public class MeasureTextField extends JTextField { + + private static final long serialVersionUID = 1L; + + public static final String VALUE_PROPERTY = "MeasureValue"; + + private final Function parser; + private final JLabel errorLabel; + + private T value; + private boolean ignoreChanges = false; + + /** + * @param text initial text + * @param parser turns non-empty text into a value, throwing {@link IllegalArgumentException} when + * it cannot + */ + public MeasureTextField(String text, Function parser) { + super(); + this.parser = parser; + setLayout(new BorderLayout()); + errorLabel = new JLabel(IconLoader.Warning.getIcon()); + errorLabel.setBorder(BorderFactory.createEmptyBorder(3, 0, 0, 0)); + add(errorLabel, BorderLayout.EAST); + setDisplayText(text); + getDocument().addDocumentListener(new DocumentListener() { + + @Override + public void changedUpdate(DocumentEvent e) { + textChanged(); + } + + @Override + public void insertUpdate(DocumentEvent e) { + textChanged(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + textChanged(); + } + }); + } + + public T getValue() { + return value; + } + + /** + * Replaces the text without firing {@link #VALUE_PROPERTY}. + */ + public void setDisplayText(String text) { + ignoreChanges = true; + try { + setText(text); + value = parseQuietly(text); + errorLabel.setVisible(value == null); + } finally { + ignoreChanges = false; + } + } + + private T parseQuietly(String text) { + try { + return text == null || text.trim().isEmpty() ? null : parser.apply(text); + } catch (IllegalArgumentException e) { + return null; + } + } + + private void textChanged() { + if (ignoreChanges) { + return; + } + T newValue; + try { + newValue = getText().trim().isEmpty() ? null : parser.apply(getText()); + } catch (IllegalArgumentException e) { + value = null; + errorLabel.setVisible(true); + return; + } + T oldValue = value; + value = newValue; + errorLabel.setVisible(false); + firePropertyChange(VALUE_PROPERTY, oldValue, newValue); + } +} diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/config/ConfigPlugin.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/config/ConfigPlugin.java index 836d27d1d..505ea5d0b 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/config/ConfigPlugin.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/config/ConfigPlugin.java @@ -40,6 +40,7 @@ import org.diylc.core.Theme; import org.diylc.utils.FlagLoader; import org.diylc.lang.LangUtil; import org.diylc.lang.Language; +import org.diylc.presenter.ValueNotationBinder; import org.diylc.swing.ActionFactory; import org.diylc.swing.ISwingUI; import org.diylc.utils.IconLoader; @@ -58,6 +59,7 @@ public class ConfigPlugin implements IPlugIn { private static final String THEME_MENU = "Theme"; private static final String RULER_MENU = "Ruler Inch Subdivision..."; private static final String SNAP_MENU = "Snap To"; + private static final String VALUE_NOTATION_MENU = "Value Notation"; private static final String CATEGORIES_MENU = "Component Categories"; private static final String COMPONENT_BROWSER_MENU = "Toolbox"; public static final String COMPONENT_BROWSER = "componentBrowser"; @@ -196,6 +198,17 @@ public class ConfigPlugin implements IPlugIn { ActionFactory.getInstance() .createConfigAction(plugInPort, "Sticky Points", IPlugInPort.STICKY_POINTS_KEY, true), CONFIG_MENU); + swingUI.injectSubmenu(VALUE_NOTATION_MENU, IconLoader.Decimal.getIcon(), CONFIG_MENU); + swingUI.injectMenuAction( + ActionFactory.getInstance().createToggleAction(IPlugInPort.VALUE_NOTATION_DECIMAL, + IPlugInPort.VALUE_NOTATION_KEY, VALUE_NOTATION_MENU, IPlugInPort.VALUE_NOTATION_DEFAULT), + VALUE_NOTATION_MENU); + swingUI.injectMenuAction( + ActionFactory.getInstance().createToggleAction(IPlugInPort.VALUE_NOTATION_RKM, + IPlugInPort.VALUE_NOTATION_KEY, VALUE_NOTATION_MENU, IPlugInPort.VALUE_NOTATION_DEFAULT), + VALUE_NOTATION_MENU); + ValueNotationBinder.bind(ConfigurationManager.getInstance(), plugInPort::refresh); + Map themeContents = ResourceUtils.getResourceContents(THEMES_DIR); if (!themeContents.isEmpty()) { XStream xStream = new XStream(new DomDriver()); diff --git a/diylc/diylc-swing/src/main/java/org/diylc/utils/IconLoader.java b/diylc/diylc-swing/src/main/java/org/diylc/utils/IconLoader.java index b8028780c..debf2c00f 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/utils/IconLoader.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/utils/IconLoader.java @@ -51,8 +51,8 @@ public enum IconLoader { "coffeebean_edit.png"), Component("component.png"), ComponentAdd( "component_add.png"), ComponentLarge("component_large.png"), ComponentPreferences( "component_preferences.png"), ComponentReplace("component_replace.png"), Copy( - "copy.png"), Cut("cut.png"), Dashboard("dashboard.png"), DataFind("data_find.png"), Delete( - "delete.png"), DiskBlue("disk_blue.png"), DistributeCentersHorizontal( + "copy.png"), Cut("cut.png"), Dashboard("dashboard.png"), DataFind("data_find.png"), Decimal( + "decimal.png"), Delete("delete.png"), DiskBlue("disk_blue.png"), DistributeCentersHorizontal( "distribute_centers_horizontal.png"), DistributeCentersVertical( "distribute_centers_vertical.png"), DistributeSpacingHorizontal( "distribute_spacing_horizontal.png"), DistributeSpacingVertical( diff --git a/diylc/diylc-swing/src/main/resources/diylc-swing-images/decimal.png b/diylc/diylc-swing/src/main/resources/diylc-swing-images/decimal.png new file mode 100644 index 000000000..8ab1aa838 Binary files /dev/null and b/diylc/diylc-swing/src/main/resources/diylc-swing-images/decimal.png differ diff --git a/diylc/diylc-swing/src/main/resources/lang/Deutsch.txt b/diylc/diylc-swing/src/main/resources/lang/Deutsch.txt index 66a7b2646..6bed24039 100644 --- a/diylc/diylc-swing/src/main/resources/lang/Deutsch.txt +++ b/diylc/diylc-swing/src/main/resources/lang/Deutsch.txt @@ -729,3 +729,6 @@ Are you sure you want to delete variant "%s"?|Möchten Sie die Variante "%s" wir Delete|Löschen Set default model|Als Standardmodell festlegen Remove default model|Standardmodell entfernen +Value Notation|Wertnotation +Decimal (4.7K, 0.22uF)|Dezimal (4.7K, 0.22uF) +RKM (4K7, 0u22)|RKM (4K7, 0u22) diff --git a/diylc/diylc-swing/src/main/resources/lang/English.txt b/diylc/diylc-swing/src/main/resources/lang/English.txt index f9e1dc6e9..3f97b0680 100644 --- a/diylc/diylc-swing/src/main/resources/lang/English.txt +++ b/diylc/diylc-swing/src/main/resources/lang/English.txt @@ -722,3 +722,6 @@ Are you sure you want to delete variant "%s"? Delete Set default model Remove default model +Value Notation +Decimal (4.7K, 0.22uF) +RKM (4K7, 0u22) diff --git a/diylc/diylc-swing/src/main/resources/lang/Español.txt b/diylc/diylc-swing/src/main/resources/lang/Español.txt index 0af19b7eb..a17c7bb3b 100644 --- a/diylc/diylc-swing/src/main/resources/lang/Español.txt +++ b/diylc/diylc-swing/src/main/resources/lang/Español.txt @@ -725,3 +725,6 @@ Are you sure you want to delete variant "%s"?|¿Seguro que desea eliminar la var Delete|Eliminar Set default model|Establecer como modelo predeterminado Remove default model|Quitar modelo predeterminado +Value Notation|Notación de valores +Decimal (4.7K, 0.22uF)|Decimal (4.7K, 0.22uF) +RKM (4K7, 0u22)|RKM (4K7, 0u22) diff --git a/diylc/diylc-swing/src/main/resources/lang/Français.txt b/diylc/diylc-swing/src/main/resources/lang/Français.txt index f37f90fa7..11af89503 100644 --- a/diylc/diylc-swing/src/main/resources/lang/Français.txt +++ b/diylc/diylc-swing/src/main/resources/lang/Français.txt @@ -724,3 +724,6 @@ Are you sure you want to delete variant "%s"?|Voulez-vous vraiment supprimer la Delete|Supprimer Set default model|Définir comme modèle par défaut Remove default model|Retirer le modèle par défaut +Value Notation|Notation des valeurs +Decimal (4.7K, 0.22uF)|Décimale (4.7K, 0.22uF) +RKM (4K7, 0u22)|RKM (4K7, 0u22) diff --git a/diylc/diylc-swing/src/main/resources/lang/Italiano.txt b/diylc/diylc-swing/src/main/resources/lang/Italiano.txt index a4f36556a..47b8b7e1a 100644 --- a/diylc/diylc-swing/src/main/resources/lang/Italiano.txt +++ b/diylc/diylc-swing/src/main/resources/lang/Italiano.txt @@ -726,3 +726,6 @@ Are you sure you want to delete variant "%s"?|Eliminare davvero la variante "%s" Delete|Elimina Set default model|Imposta come modello predefinito Remove default model|Rimuovi modello predefinito +Value Notation|Notazione dei valori +Decimal (4.7K, 0.22uF)|Decimale (4.7K, 0.22uF) +RKM (4K7, 0u22)|RKM (4K7, 0u22) diff --git a/diylc/diylc-swing/src/main/resources/lang/Nederlands.txt b/diylc/diylc-swing/src/main/resources/lang/Nederlands.txt index f21d47a38..e44e2ef07 100644 --- a/diylc/diylc-swing/src/main/resources/lang/Nederlands.txt +++ b/diylc/diylc-swing/src/main/resources/lang/Nederlands.txt @@ -730,3 +730,6 @@ Are you sure you want to delete variant "%s"?|Weet u zeker dat u variant "%s" wi Delete|Verwijderen Set default model|Instellen als standaardmodel Remove default model|Standaardmodel verwijderen +Value Notation|Waardenotatie +Decimal (4.7K, 0.22uF)|Decimaal (4.7K, 0.22uF) +RKM (4K7, 0u22)|RKM (4K7, 0u22) diff --git a/diylc/diylc-swing/src/main/resources/lang/Português.txt b/diylc/diylc-swing/src/main/resources/lang/Português.txt index d4d3c1f5c..bf7070c1a 100644 --- a/diylc/diylc-swing/src/main/resources/lang/Português.txt +++ b/diylc/diylc-swing/src/main/resources/lang/Português.txt @@ -725,3 +725,6 @@ Are you sure you want to delete variant "%s"?|Tem certeza de que deseja excluir Delete|Excluir Set default model|Definir como modelo padrão Remove default model|Remover modelo padrão +Value Notation|Notação de valores +Decimal (4.7K, 0.22uF)|Decimal (4.7K, 0.22uF) +RKM (4K7, 0u22)|RKM (4K7, 0u22) diff --git a/diylc/diylc-swing/src/main/resources/lang/Srpski.txt b/diylc/diylc-swing/src/main/resources/lang/Srpski.txt index 6c17ce239..97b5a139c 100644 --- a/diylc/diylc-swing/src/main/resources/lang/Srpski.txt +++ b/diylc/diylc-swing/src/main/resources/lang/Srpski.txt @@ -721,3 +721,6 @@ Are you sure you want to delete variant "%s"?|Da li ste sigurni da želite da ob Delete|Obriši Set default model|Postavi kao podrazumevani model Remove default model|Ukloni podrazumevani model +Value Notation|Zapis vrednosti +Decimal (4.7K, 0.22uF)|Decimalni (4.7K, 0.22uF) +RKM (4K7, 0u22)|RKM (4K7, 0u22) diff --git a/diylc/diylc-swing/src/main/resources/lang/हिंदी.txt b/diylc/diylc-swing/src/main/resources/lang/हिंदी.txt index 813bd70bc..6c5264a57 100644 --- a/diylc/diylc-swing/src/main/resources/lang/हिंदी.txt +++ b/diylc/diylc-swing/src/main/resources/lang/हिंदी.txt @@ -723,3 +723,6 @@ Are you sure you want to delete variant "%s"?|क्या आप वाकई Delete|हटाएँ Set default model|डिफ़ॉल्ट मॉडल सेट करें Remove default model|डिफ़ॉल्ट मॉडल हटाएँ +Value Notation|मान संकेतन +Decimal (4.7K, 0.22uF)|दशमलव (4.7K, 0.22uF) +RKM (4K7, 0u22)|RKM (4K7, 0u22) diff --git a/diylc/diylc-swing/src/main/resources/lang/简体中文.txt b/diylc/diylc-swing/src/main/resources/lang/简体中文.txt index 6883ac1cd..8e8eb75f0 100644 --- a/diylc/diylc-swing/src/main/resources/lang/简体中文.txt +++ b/diylc/diylc-swing/src/main/resources/lang/简体中文.txt @@ -723,3 +723,6 @@ Are you sure you want to delete variant "%s"?|确定要删除变体“%s”吗 Delete|删除 Set default model|设为默认型号 Remove default model|取消默认型号 +Value Notation|数值表示法 +Decimal (4.7K, 0.22uF)|小数 (4.7K, 0.22uF) +RKM (4K7, 0u22)|RKM (4K7, 0u22) diff --git a/diylc/docs/plans/rkm-value-notation.md b/diylc/docs/plans/rkm-value-notation.md new file mode 100644 index 000000000..a3d3b5dc5 --- /dev/null +++ b/diylc/docs/plans/rkm-value-notation.md @@ -0,0 +1,185 @@ +# RKM Value Notation for Resistance and Capacitance + +Let users read and type resistor and capacitor values in RKM (IEC 60062) notation, where the unit +letter replaces the decimal point (`4K7`, `0R22`, `2n2`, `0u22`), as well as in today's decimal +form (`4.7K`, `0.22uF`). Typing a value with a unit letter in it, in either notation, also sets the +unit, so `4K7` typed into a field whose unit is `Ω` becomes 4.7 K. + +## 1. User-facing behaviour + +### Config menu + +One radio submenu in `ConfigPlugin` covering both resistance and capacitance, built the same way +as `Snap To` and `Ruler Inch Subdivision...` (`ToggleAction`, string config key, title stored as +the value): + +``` +Config + ... + Value Notation > (after Sticky Points, alphabetical like the rest) + (o) Decimal (4.7K, 0.22uF) + ( ) RKM (4K7, 0u22) + ... +``` + +- Key `IPlugInPort.VALUE_NOTATION_KEY = "valueNotation"`, values `VALUE_NOTATION_DECIMAL` / + `VALUE_NOTATION_RKM`, default decimal. +- Switching notation repaints the canvas immediately, so labels on the board change at once. + +### What the notation controls (display) + +The notation governs `Resistance.toString()` and `Capacitance.toString()` and therefore everything +built on them: labels drawn on the canvas, `getValueForDisplay()` (search, BOM, netlist text, +project explorer, AI assistant context). The edit dialog's value field is the exception: it always +shows the bare decimal number, because the unit box beside it already carries the unit and an RKM +code there would repeat it. + +| Value | Decimal (unchanged) | RKM | +| --- | --- | --- | +| 4.7 Ω | `4.7Ω` | `4R7` | +| 0.22 Ω | `0.22Ω` | `0R22` | +| 100 Ω | `100Ω` | `100R` | +| 4.7 K | `4.7K` | `4K7` | +| 47 K | `47K` | `47K` | +| 1 M | `1M` | `1M` | +| 0.22 uF | `0.22uF` | `0u22` | +| 2.2 nF | `2.2nF` | `2n2` | +| 100 nF | `100nF` | `100n` | +| 4.7 pF | `4.7pF` | `4p7` | +| 1000 uF | `1000uF` | `1000u` | + +Rules: RKM keeps the stored unit (it never renormalises 4700 Ω to 4K7), uses `R` for ohms, drops +the trailing `F` on capacitance, puts the unit letter where the decimal point was, and writes it +as a suffix for whole numbers. The digits after the letter are rounded the same way as today's +`0.####` decimal format. + +### What the user can type (input) + +The value field accepts, in **both** notations: + +- a bare number (`4.7`, `0,22`) — the unit is unchanged, as today; +- a decimal number with a unit (`4.7k`, `0.22uF`, `220 nF`, `4.7ohm`, `4.7Ω`); +- an RKM code (`4K7`, `4k7`, `0R22`, `R22`, `2n2`, `0u22`, `4µ7`, `1M`, `100R`). + +When the text carries a unit, the unit combo switches to it and the property gets the new value +and unit together. Accepted unit letters, case-insensitive except where noted: + +- Resistance: `R`, `Ω`, `ohm`/`ohms` → Ω; `K`/`k` → K; `M`/`m`/`Meg` → M (there is no milliohm + unit, so a lower-case `m` on a resistor means mega, as it does on many parts lists). +- Capacitance: `p` → pF; `n` → nF; `u`/`µ` → uF; `m` → mF (**case-sensitive**: `M` is not + accepted, to avoid reading "mega"); `F` alone → F. A trailing `F` after any of these is + optional (`4u7F`, `2.2nF`). + +A unit letter that does not belong to the measure (`4u7` on a resistor) shows the existing warning +icon, as any unparseable text does today. + +**Commit behaviour.** The property is updated on every keystroke, as today — the edit dialog's +Enter key commits straight away, so it cannot wait for focus loss. When the field loses focus, +the text is rewritten as the bare decimal number (`4K7` typed → `4.7` with the combo showing `K`), +whichever notation is selected. The text is not reformatted while the user is typing, so the +caret never jumps. + +## 2. Design + +### Core (`diylc-core`, `org.diylc.core.measures`) + +- **`ValueNotation`** — new enum `DECIMAL, RKM` holding the formatting and parsing logic, so the + two measure classes stay thin: + - `String format(double value, Unit unit, String rkmSymbol)` — decimal falls through to the + existing `0.####` + `unit.toString()`; RKM implements the rules in §1. + - `static ParsedMeasure parse(String text, ...)` — returns value + unit (unit `null` for a bare + number), or throws `IllegalArgumentException`. One small record `ParsedMeasure` at the + bottom of the file. +- **Current notation.** One `private static volatile ValueNotation current` on `ValueNotation` + with static `getCurrent()` / `setCurrent()`, shared by `Resistance` and `Capacitance`. + `toString()` reads the field, never + `ConfigurationManager`: it runs from `draw()` on every repaint. Default `DECIMAL`, so headless + rendering, the regression suite and unit tests are unchanged unless they opt in. +- **Unit display symbol for RKM.** `ResistanceUnit` and `CapacitanceUnit` get an `rkmSymbol` + constructor argument (`R`/`K`/`M`, `p`/`n`/`u`/`m`/`F`). Enum constant names and `toString()` + are untouched — XStream stores the constant name, so files are unaffected. +- **Parsing.** `Resistance.parseResistance` / `Capacitance.parseCapacitance` delegate to + `ValueNotation.parse`, so RKM is accepted everywhere a string is turned into a value: the + editor, `V1FileParser`, `TangoNetlistParser`, `CapacitorDatasheetService` and — important once + `toString()` can emit `4K7` — the AI assistant, which receives values as text and writes them + back through these parsers. Existing accepted inputs must keep parsing identically. +- `Inductance` and `Power` are out of scope; their names are left as they are. + +### Wiring the setting + +`org.diylc.presenter.ValueNotationBinder.bind(configManager, refresh)` reads the key once, +pushes it into `ValueNotation.setCurrent()`, and on change updates the static, calls +`DrawingCache.Instance.clear()` (cached component images contain the old labels) and refreshes +the canvas. It is called from `ConfigPlugin.connect()`, not from the `Presenter` constructor: +the notation is global, and the many secondary presenters (project preview, cloud thumbnails, +batch converter, regression runner) are built on in-memory configurations and would reset it to +decimal. + +### Swing (`diylc-swing`) + +- **`MeasureTextField`** — new field in `org.diylc.swing.gui.editor`, beside `MeasureEditor`. + Same look as `DoubleTextField` (warning icon on the right) but it parses through + `ValueNotation.parse` for the measure type and fires a `MEASURE_PROPERTY` change carrying + value + optional unit; on `focusLost` it reformats its text. It is generic over the unit enum + so it can serve `Inductance` later without changes. + + Recommendation: keep it in DIYLC rather than extending `DoubleTextField` in + `bancika-gui-framework`. The framework is consumed as the vendored + `lib/swing-framework-1.1.jar`, so a change there means rebuilding and re-vendoring the jar for a + widget whose parsing rules are DIYLC domain knowledge. `DoubleTextField` keeps serving + `PercentageEditor`, `LoadlineEditorFrame` and every other plain-number measure. + + One `DoubleTextField` bug is worth fixing in the framework separately, and the new field must + not copy it: `DecimalFormat.parseObject(String)` accepts a valid prefix, so `4R7` silently + parses as `4` today, with no warning. +- **`MeasureEditor`** — uses `MeasureTextField` when the property type is `Resistance` or + `Capacitance`, `DoubleTextField` otherwise. On a value + unit event it sets the combo's + selected unit behind a guard flag, so the combo's own `ActionListener` does not fire a second + `setValue`. Read-only properties keep today's behaviour (disabled field, combo converts). +- **`MeasureArrayEditor`** (`MultiSectionCapacitor`) — each section value is parsed with the same + rules. Units travel per section in the text (`47u / 22u / 10u`) and a section without a unit + takes the combo's unit, as today. The combo shows the first section's unit; in decimal notation a + section in a different unit is written with its unit (`47 / 100nF`). Changing the combo applies + its unit to every section, as before. +- **`ConfigPlugin`** — the `Value Notation` submenu (`IconLoader.Decimal`), and the call to + `ValueNotationBinder.bind`. + +### Localization + +New keys in `English.txt` and every other file in `lang/`: `Value Notation`, +`Decimal (4.7K, 0.22uF)`, `RKM (4K7, 0u22)`. +Because `ToggleAction` stores its title as the config value, the stored values are the English +titles and `MainFrame` translates only what is shown. + +## 3. Compatibility + +- `.diy` files store `value` and the unit enum name; neither changes. No serialization impact. +- With the default (decimal) every string the app produces is byte-identical to today, so + regression references do not move. A regression run is still worth doing after implementation, + since `parseResistance` / `parseCapacitance` change. +- A user who switches to RKM gets RKM in BOM and netlist exports as well. That is the intended + "everywhere" behaviour; anything that re-imports those files goes through the RKM-aware + parsers. + +## 4. Tests (`diylc-core`, JUnit 4) + +`ValueNotationTest`: + +- formatting: every row of the §1 table, both notations; zero, null, values ≥ 1000 in their unit, + rounding at the fourth decimal; +- parsing: every input form in §1, per measure; case rules (`m` vs `M` for capacitance); comma as + decimal separator; rejects `4u7` on a resistor, `4K7K`, `K`, empty text; +- round trip: `parse(format(x))` returns `x` for both notations; +- regression guard: every string currently accepted by `parseResistance` / `parseCapacitance` + (the inputs in `V1FileParser`, `TangoNetlistParser` and `CapacitorDatasheetService` tests and + `AiEditScriptEditorTest`) still parses to the same value. + +## 5. Status and next steps + +Implemented as described; `mvn -q test` passes, including `ValueNotationTest`. + +1. Try it in the running app: the edit dialog (single value and `MultiSectionCapacitor`), canvas + labels after switching notation, BOM export. +2. Run the regression suite to confirm decimal output is unchanged. +3. Optional, in `bancika-gui-framework`: stop `DoubleTextField` / `DoubleArrayTextField` + accepting a valid prefix (`4R7` read as `4`).