Added 'Value Notation' to the Config menu

This commit is contained in:
Bane Stojkovic
2026-11-09 21:44:17 +01:00
parent bff9e05843
commit b2f99c7022
26 changed files with 1174 additions and 118 deletions
@@ -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";
@@ -41,14 +41,16 @@ public class Capacitance extends AbstractMeasure<CapacitanceUnit> {
}
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<CapacitanceUnit> 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<CapacitanceUnit> {
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();
}
}
@@ -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;
}
}
}
@@ -109,14 +109,16 @@ public class Resistance extends AbstractMeasure<ResistanceUnit> {
}
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<ResistanceUnit> 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<ResistanceUnit> {
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();
}
}
@@ -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;
}
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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 <U extends Unit> ParsedMeasure<U> parse(String text,
Function<String, U> 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<U>(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<U>(matcher.group(1).isEmpty() ? parsed : -parsed, unit);
}
}
throw new IllegalArgumentException("Could not parse value: " + text);
}
public record ParsedMeasure<U extends Unit>(double value, U unit) {
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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);
}
}
@@ -629,6 +629,10 @@
<releaseDate>2026-11-07 00:00:00.000 CET</releaseDate>
<name></name>
<changes>
<org.diylc.appframework.update.Change>
<changeType>NEW_FEATURE</changeType>
<description>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</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>BUG_FIX</changeType>
<description>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</description>
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<ResistanceUnit> 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
}
}
}
@@ -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<Object> 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<String, Unit> unitResolver = MeasureEditor
.getUnitResolver(property.getType().getComponentType());
if (unitResolver == null) {
valueField = createDoubleField(property, measure);
} else {
final MeasureTextField<List<ParsedMeasure<Unit>>> measureField = new MeasureTextField<List<ParsedMeasure<Unit>>>(
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<ParsedMeasure<Unit>> parsed = (List<ParsedMeasure<Unit>>) 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<List<ParsedMeasure<Unit>>> measureField = (MeasureTextField<List<ParsedMeasure<Unit>>>) 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<ParsedMeasure<Unit>> 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<Unit> 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<ParsedMeasure<Unit>> parseSections(String text,
Function<String, Unit> unitResolver) {
List<ParsedMeasure<Unit>> sections = new ArrayList<ParsedMeasure<Unit>>();
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<String> parts = new ArrayList<String>();
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();
@@ -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<Object> 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<String, Unit> 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<ParsedMeasure<Unit>> measureField =
new MeasureTextField<ParsedMeasure<Unit>>(formatForField(measure, true),
text -> ValueNotation.parse(text, unitResolver));
measureField.addPropertyChangeListener(MeasureTextField.VALUE_PROPERTY,
new PropertyChangeListener() {
@Override
public void propertyChange(PropertyChangeEvent evt) {
ParsedMeasure<Unit> parsed = (ParsedMeasure<Unit>) 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<? extends Unit>) 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<String, Unit> 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<Unit> parsed = ((MeasureTextField<ParsedMeasure<Unit>>) 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<ParsedMeasure<Unit>>) valueField).setDisplayText(formatForField(measure, true));
}
}
private AbstractMeasure<?> convertUnitsIfNeeded(AbstractMeasure<?> value) {
if (ConfigurationManager.getInstance().readBoolean(IPlugInPort.AUTO_UNIT_CONVERSION_KEY, true)
&& value instanceof Size sizeValue) {
@@ -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 <http://www.gnu.org/licenses/>.
*/
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 <T> type of the parsed value
*/
public class MeasureTextField<T> extends JTextField {
private static final long serialVersionUID = 1L;
public static final String VALUE_PROPERTY = "MeasureValue";
private final Function<String, T> 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<String, T> 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);
}
}
@@ -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<String, String> themeContents = ResourceUtils.getResourceContents(THEMES_DIR);
if (!themeContents.isEmpty()) {
XStream xStream = new XStream(new DomDriver());
@@ -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(
Binary file not shown.

After

Width:  |  Height:  |  Size: 769 B

@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
+185
View File
@@ -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<U>` 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`).