From 581257a0d6be9868464b7ec6e7ba8c1ca2cc82ea Mon Sep 17 00:00:00 2001 From: Emilio Heredia Date: Fri, 18 Sep 2026 13:40:06 -0600 Subject: [PATCH 1/7] refactor(display): extract RTScaledWidgetRepresentation and add show_scale_labels to Tank Move everything in TankRepresentation that only depends on the ScaledPVWidget contract into an abstract RTScaledWidgetRepresentation: creating the RTTank, forwarding value and range updates, evaluating the alarm limits, the orientation transform and the update scheduling. TankRepresentation keeps the Tank specific listeners and colors. The behaviour of the Tank widget does not change. Add a 'show_scale_labels' property (default true) to the Tank so that stacked widgets can share one labelled scale: with the property off, YAxisImpl draws the tick marks and the axis line only. Tick positions are the same as with labels. TankWidgetUnitTest covers the new property. --- .../display/builder/model/Messages.java | 1 + .../builder/model/widgets/ScaledPVWidget.java | 31 +- .../builder/model/widgets/TankWidget.java | 29 +- .../display/builder/model/messages.properties | 1 + .../builder/model/messages_fr.properties | 1 + .../model/widgets/TankWidgetUnitTest.java | 4 + .../widgets/RTScaledWidgetRepresentation.java | 303 ++++++++++++++++++ .../javafx/widgets/TankRepresentation.java | 238 ++------------ .../org/csstudio/javafx/rtplot/RTTank.java | 11 + .../javafx/rtplot/internal/YAxisImpl.java | 35 +- .../javafx/rtplot/internal/YAxisImplTest.java | 55 ++++ 11 files changed, 474 insertions(+), 235 deletions(-) create mode 100644 app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTScaledWidgetRepresentation.java create mode 100644 app/rtplot/src/test/java/org/csstudio/javafx/rtplot/internal/YAxisImplTest.java diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java index 1f56ddea77..27bdbcc778 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java @@ -326,6 +326,7 @@ public class Messages WidgetProperties_ShowLoLo, WidgetProperties_ShowMinorTicks, WidgetProperties_PerpendicularTickLabels, + WidgetProperties_ShowScaleLabels, WidgetProperties_ShowOK, WidgetProperties_ShowScale, WidgetProperties_ShowUnits, diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ScaledPVWidget.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ScaledPVWidget.java index 6f8e5ecd11..bdfd7af1b1 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ScaledPVWidget.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ScaledPVWidget.java @@ -22,10 +22,10 @@ import org.csstudio.display.builder.model.WidgetProperty; import org.csstudio.display.builder.model.WidgetPropertyCategory; import org.csstudio.display.builder.model.WidgetPropertyDescriptor; -import org.phoebus.ui.color.NamedWidgetColors; -import org.phoebus.ui.color.WidgetColorService; import org.csstudio.display.builder.model.properties.EnumWidgetProperty; +import org.phoebus.ui.color.NamedWidgetColors; import org.phoebus.ui.color.WidgetColor; +import org.phoebus.ui.color.WidgetColorService; import org.phoebus.ui.vtype.ScaleFormat; /** Base class for PV widgets that display a numeric value on a scale @@ -46,7 +46,7 @@ * overrides the manual LOLO/LO/HI/HIHI levels. New property; * old Phoebus silently ignores the XML element. *
  • Manual {@code minimum} / {@code maximum} range.
  • - *
  • A {@code show_limits} toggle for alarm-limit visual markers.
  • + *
  • A {@code show_alarm_limits} toggle for alarm-limit visual markers.
  • *
  • Manual LOLO / LO / HI / HIHI thresholds (NaN = inactive).
  • *
  • Configurable minor/major alarm colours defaulting to the named * {@code ALARM_MINOR} / {@code ALARM_MAJOR} palette entries.
  • @@ -125,6 +125,31 @@ public EnumWidgetProperty createProperty(final Widget widget, newColorPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "major_alarm_color", Messages.WidgetProperties_MajorAlarmColor); + /** 'scale_visible': show the numeric scale (tick marks and labels) */ + public static final WidgetPropertyDescriptor propScaleVisible = + newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "scale_visible", + Messages.WidgetProperties_ScaleVisible); + + /** 'show_minor_ticks': show minor tick marks on the scale */ + public static final WidgetPropertyDescriptor propShowMinorTicks = + newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "show_minor_ticks", + Messages.WidgetProperties_ShowMinorTicks); + + /** 'opposite_scale_visible': show a second scale on the opposite side */ + public static final WidgetPropertyDescriptor propOppositeScaleVisible = + newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "opposite_scale_visible", + Messages.WidgetProperties_OppositeScaleVisible); + + /** 'perpendicular_tick_labels': draw scale labels perpendicular to the axis */ + public static final WidgetPropertyDescriptor propPerpendicularTickLabels = + newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "perpendicular_tick_labels", + Messages.WidgetProperties_PerpendicularTickLabels); + + /** 'show_scale_labels': show tick label text on the scale (ticks are always drawn) */ + public static final WidgetPropertyDescriptor propShowScaleLabels = + newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "show_scale_labels", + Messages.WidgetProperties_ShowScaleLabels); + // ---- Instance fields ------------------------------------------------ private volatile WidgetProperty format; diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/TankWidget.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/TankWidget.java index 48666c55da..b947edb2a5 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/TankWidget.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/TankWidget.java @@ -7,7 +7,6 @@ *******************************************************************************/ package org.csstudio.display.builder.model.widgets; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newBooleanPropertyDescriptor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newColorPropertyDescriptor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newIntegerPropertyDescriptor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propBackgroundColor; @@ -84,26 +83,6 @@ public Widget createWidget() /** 'empty_color' */ public static final WidgetPropertyDescriptor propEmptyColor = newColorPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "empty_color", Messages.WidgetProperties_EmptyColor); - /** 'scale_visible' */ - public static final WidgetPropertyDescriptor propScaleVisible = - newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "scale_visible", Messages.WidgetProperties_ScaleVisible); - - /** 'show_minor_ticks' */ - public static final WidgetPropertyDescriptor propShowMinorTicks = - newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "show_minor_ticks", Messages.WidgetProperties_ShowMinorTicks); - - /** 'perpendicular_tick_labels' — draw scale labels perpendicular - * to the axis direction (horizontal text beside vertical scale) - */ - public static final WidgetPropertyDescriptor propPerpendicularTickLabels = - newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "perpendicular_tick_labels", Messages.WidgetProperties_PerpendicularTickLabels); - - /** 'opposite_scale_visible' — show a second scale on the opposite - * side of the tank (right for vertical, bottom for horizontal). - * Inspired by CS-Studio BOY which could show markers on both sides. - */ - public static final WidgetPropertyDescriptor propOppositeScaleVisible = - newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "opposite_scale_visible", Messages.WidgetProperties_OppositeScaleVisible); /** Widget configurator to read legacy *.opi files*/ private static class CustomConfigurator extends WidgetConfigurator @@ -168,6 +147,7 @@ public WidgetConfigurator getConfigurator(final Version persisted_version) private volatile WidgetProperty empty_color; private volatile WidgetProperty scale_visible; private volatile WidgetProperty show_minor_ticks; + private volatile WidgetProperty showScaleLabels; private volatile WidgetProperty perpendicular_tick_labels; private volatile WidgetProperty opposite_scale_visible; private volatile WidgetProperty log_scale; @@ -193,6 +173,7 @@ protected void defineProperties(final List> properties) properties.add(scale_visible = propScaleVisible.createProperty(this, true)); properties.add(opposite_scale_visible = propOppositeScaleVisible.createProperty(this, false)); properties.add(show_minor_ticks = propShowMinorTicks.createProperty(this, true)); + properties.add(showScaleLabels = propShowScaleLabels.createProperty(this, true)); properties.add(perpendicular_tick_labels = propPerpendicularTickLabels.createProperty(this, false)); properties.add(log_scale = propLogscale.createProperty(this, false)); properties.add(horizontal = propHorizontal.createProperty(this, false)); @@ -250,6 +231,12 @@ public WidgetProperty propShowMinorTicks() return show_minor_ticks; } + /** @return 'show_scale_labels' property */ + public WidgetProperty propShowScaleLabels() + { + return showScaleLabels; + } + /** @return 'perpendicular_tick_labels' property */ public WidgetProperty propPerpendicularTickLabels() { diff --git a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties index 278fcc4753..586c0cf6c7 100644 --- a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties +++ b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties @@ -310,6 +310,7 @@ WidgetProperties_ShowLimits=Show Limits WidgetProperties_ShowLow=Show Low WidgetProperties_ShowLoLo=Show LoLo WidgetProperties_ShowMinorTicks=Show minor ticks +WidgetProperties_ShowScaleLabels=Show scale labels WidgetProperties_PerpendicularTickLabels=Labels perpendicular to axis WidgetProperties_ShowOK=Show OK WidgetProperties_ShowScale=Show Scale diff --git a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties index a5764e7ef7..3bc18b6855 100644 --- a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties +++ b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties @@ -310,6 +310,7 @@ WidgetProperties_ShowLimits=Afficher les limites WidgetProperties_ShowLow=Afficher Low WidgetProperties_ShowLoLo=Afficher LoLo WidgetProperties_ShowMinorTicks=Afficher les petites graduations +WidgetProperties_ShowScaleLabels=Afficher les labels de l'échelle WidgetProperties_PerpendicularTickLabels=Labels perpendiculaires à l'axe WidgetProperties_ShowOK=Afficher OK WidgetProperties_ShowScale=Afficher l'échelle diff --git a/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/TankWidgetUnitTest.java b/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/TankWidgetUnitTest.java index f544fa699e..57fe2b840b 100644 --- a/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/TankWidgetUnitTest.java +++ b/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/TankWidgetUnitTest.java @@ -77,6 +77,7 @@ public void testTankWidgetDefaults() assertThat(tank.propScaleVisible().getValue(), equalTo(true)); assertThat(tank.propOppositeScaleVisible().getValue(), equalTo(false)); assertThat(tank.propShowMinorTicks().getValue(), equalTo(true)); + assertThat(tank.propShowScaleLabels().getValue(), equalTo(true)); assertThat(tank.propPerpendicularTickLabels().getValue(), equalTo(false)); assertThat(tank.propFormat().getValue(), equalTo(ScaleFormat.DEFAULT)); assertThat(tank.propPrecision().getValue(), equalTo(2)); @@ -138,6 +139,7 @@ public void testXmlRoundTrip() throws Exception original.propOppositeScaleVisible().setValue(true); original.propBorderWidth().setValue(3); original.propPerpendicularTickLabels().setValue(true); + original.propShowScaleLabels().setValue(false); original.propFormat().setValue(ScaleFormat.DECIMAL); original.propPrecision().setValue(3); @@ -184,6 +186,7 @@ public void testXmlRoundTrip() throws Exception assertThat(tank.propOppositeScaleVisible().getValue(), equalTo(true)); assertThat(tank.propBorderWidth().getValue(), equalTo(3)); assertThat(tank.propPerpendicularTickLabels().getValue(), equalTo(true)); + assertThat(tank.propShowScaleLabels().getValue(), equalTo(false)); assertThat(tank.propFormat().getValue(), equalTo(ScaleFormat.DECIMAL)); assertThat(tank.propPrecision().getValue(), equalTo(3)); } @@ -213,5 +216,6 @@ public void testNewPropertiesAreOptional() throws Exception assertThat(xml, not(containsString(""))); assertThat(xml, not(containsString(""))); assertThat(xml, not(containsString(""))); + assertThat(xml, not(containsString(""))); } } diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTScaledWidgetRepresentation.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTScaledWidgetRepresentation.java new file mode 100644 index 0000000000..4918901a5c --- /dev/null +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTScaledWidgetRepresentation.java @@ -0,0 +1,303 @@ +/******************************************************************************* + * Copyright (c) 2015-2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.csstudio.display.builder.representation.javafx.widgets; + +import java.util.concurrent.TimeUnit; + +import org.csstudio.display.builder.model.DirtyFlag; +import org.csstudio.display.builder.model.UntypedWidgetPropertyListener; +import org.csstudio.display.builder.model.WidgetProperty; +import org.csstudio.display.builder.model.WidgetPropertyListener; +import org.csstudio.display.builder.model.util.VTypeUtil; +import org.csstudio.display.builder.model.widgets.ScaledPVWidget; +import org.csstudio.display.builder.representation.Preferences; +import org.csstudio.display.builder.representation.javafx.JFXUtil; +import org.csstudio.javafx.rtplot.RTTank; +import org.epics.util.stats.Range; +import org.epics.vtype.Display; +import org.epics.vtype.VType; + +import javafx.scene.layout.Pane; +import javafx.scene.transform.Rotate; +import javafx.scene.transform.Translate; + +/** Abstract base for widget representations whose JFX node is an {@link RTTank}. + * + *

    Handles all logic that depends only on the {@link ScaledPVWidget} contract: + *

      + *
    • Creating and throttle-configuring the {@link RTTank}
    • + *
    • Forwarding PV value and display-range changes to the tank
    • + *
    • Evaluating alarm limit lines from PV metadata or widget properties
    • + *
    • Orientation transform (rotation for horizontal layout)
    • + *
    • Scheduling representation updates on property changes
    • + *
    + * + *

    Subclasses provide: + *

      + *
    • {@link #isHorizontal()}: the widget's own {@code horizontal} property
    • + *
    • {@link #registerLookListeners()} / {@link #unregisterLookListeners()}: + * listeners on the widget-specific appearance properties + * (colors, scale visibility, font, ...)
    • + *
    • {@link #applyLookToTank()}: push the current appearance properties + * to the tank after size and orientation have been set
    • + *
    + * + * @param concrete {@link ScaledPVWidget} subtype + */ +public abstract class RTScaledWidgetRepresentation + extends RegionBaseRepresentation +{ + /** The rendering canvas shared by all RTTank-based widgets. */ + protected volatile RTTank tank; + + /** Dirty flag for appearance (color, scale, size). Value updates do not + * set this, they bypass the JFX representation update cycle entirely by + * calling {@link RTTank#setValue} directly. */ + protected final DirtyFlag dirtyLook = new DirtyFlag(); + + /** Marks appearance dirty and schedules an update. Shared by subclass + * listeners on color / scale / font properties. */ + protected final UntypedWidgetPropertyListener lookListener = + (p, o, n) -> { dirtyLook.mark(); toolkit.scheduleUpdate(this); }; + + /** Forwards PV value or display-range changes to the tank immediately. */ + private final UntypedWidgetPropertyListener valueListener = this::valueChanged; + + /** Re-evaluates and pushes alarm limit lines whenever a limit property changes. */ + private final UntypedWidgetPropertyListener limitsListener = this::limitsChanged; + + /** Swaps width and height in the editor and triggers a look update. */ + protected final WidgetPropertyListener orientationChangedListener = + this::orientationChanged; + + /** Whether orientation transforms are currently applied to the tank node. */ + private boolean wasTransformed = false; + + @Override + public Pane createJFXNode() throws Exception + { + tank = new RTTank(); + tank.setUpdateThrottle(Preferences.image_update_delay, TimeUnit.MILLISECONDS); + return new Pane(tank); + } + + /** Register listeners on the {@link ScaledPVWidget} value and limit + * properties, then call {@link #registerLookListeners()} for the + * subclass to add its widget-specific appearance listeners. + *

    The current range, value and limits are applied once at the end. */ + @Override + protected void registerListeners() + { + super.registerListeners(); + + // Value / range + model_widget.propLimitsFromPV().addUntypedPropertyListener(valueListener); + model_widget.propMinimum().addUntypedPropertyListener(valueListener); + model_widget.propMaximum().addUntypedPropertyListener(valueListener); + model_widget.runtimePropValue().addUntypedPropertyListener(valueListener); + + // Alarm limit lines + model_widget.propShowAlarmLimits().addUntypedPropertyListener(limitsListener); + model_widget.propAlarmLimitsFromPV().addUntypedPropertyListener(limitsListener); + model_widget.propLevelLoLo().addUntypedPropertyListener(limitsListener); + model_widget.propLevelLow().addUntypedPropertyListener(limitsListener); + model_widget.propLevelHigh().addUntypedPropertyListener(limitsListener); + model_widget.propLevelHiHi().addUntypedPropertyListener(limitsListener); + + // Alarm color changes only affect appearance, not limits + model_widget.propMinorAlarmColor().addUntypedPropertyListener(lookListener); + model_widget.propMajorAlarmColor().addUntypedPropertyListener(lookListener); + + // Widget-specific look properties (colors, scale, font, ...) + registerLookListeners(); + + // Apply the current state, range first, then limits + valueChanged(null, null, null); + limitsChanged(null, null, null); + } + + /** Register listeners on widget-specific appearance properties. + * The implementation should add listeners using {@link #lookListener} + * (or a dedicated listener) and call nothing on the tank directly, + * that happens in {@link #applyLookToTank()}. */ + protected abstract void registerLookListeners(); + + @Override + protected void unregisterListeners() + { + model_widget.propLimitsFromPV().removePropertyListener(valueListener); + model_widget.propMinimum().removePropertyListener(valueListener); + model_widget.propMaximum().removePropertyListener(valueListener); + model_widget.runtimePropValue().removePropertyListener(valueListener); + + model_widget.propShowAlarmLimits().removePropertyListener(limitsListener); + model_widget.propAlarmLimitsFromPV().removePropertyListener(limitsListener); + model_widget.propLevelLoLo().removePropertyListener(limitsListener); + model_widget.propLevelLow().removePropertyListener(limitsListener); + model_widget.propLevelHigh().removePropertyListener(limitsListener); + model_widget.propLevelHiHi().removePropertyListener(limitsListener); + + model_widget.propMinorAlarmColor().removePropertyListener(lookListener); + model_widget.propMajorAlarmColor().removePropertyListener(lookListener); + + unregisterLookListeners(); + super.unregisterListeners(); + } + + /** Unregister the listeners added by {@link #registerLookListeners()}. */ + protected abstract void unregisterLookListeners(); + + /** Called on every PV value update and on range-related property changes. + * Updates the range, the alarm limits from the PV metadata and the + * fill level of the tank. */ + private void valueChanged(final WidgetProperty prop, + final Object oldValue, final Object newValue) + { + final VType vtype = model_widget.runtimePropValue().getValue(); + final boolean limitsFromPV = model_widget.propLimitsFromPV().getValue(); + updateRange(vtype, limitsFromPV); + + if (model_widget.propAlarmLimitsFromPV().getValue()) + applyAlarmLimits(vtype); + + // In edit mode there is no PV, so the widget range is the effective range + final double min = model_widget.propMinimum().getValue(); + final double max = model_widget.propMaximum().getValue(); + final double value = toolkit.isEditMode() + ? (min + max) / 2.0 + : VTypeUtil.getValueNumber(vtype).doubleValue(); + tank.setValue(value); + } + + /** Push the display range to the tank. + * When {@code limitsFromPV} is {@code true}, reads the range from PV + * display metadata and falls back to widget properties when metadata is + * unavailable. When {@code false}, uses the widget properties directly. + * + * @param vtype current PV value (may be {@code null} before connect) + * @param limitsFromPV whether the range should come from the PV */ + private void updateRange(final VType vtype, final boolean limitsFromPV) + { + double min = model_widget.propMinimum().getValue(); + double max = model_widget.propMaximum().getValue(); + if (limitsFromPV) + { + final Display displayInfo = Display.displayOf(vtype); + if (displayInfo != null && displayInfo.getDisplayRange().isFinite()) + { + min = displayInfo.getDisplayRange().getMinimum(); + max = displayInfo.getDisplayRange().getMaximum(); + } + } + tank.setRange(min, max); + } + + /** Triggered when any alarm limit property changes; delegates to + * {@link #applyAlarmLimits(VType)} with the current PV value. */ + private void limitsChanged(final WidgetProperty property, + final Object oldValue, final Object newValue) + { + applyAlarmLimits(model_widget.runtimePropValue().getValue()); + } + + /** Resolves alarm limits from PV metadata or widget properties (depending on + * {@code alarm_limits_from_pv}) and pushes them to the tank. + * Clears all limit lines when {@code show_alarm_limits} is {@code false}. */ + private void applyAlarmLimits(final VType vtype) + { + if (!model_widget.propShowAlarmLimits().getValue()) + { + tank.setLimits(Double.NaN, Double.NaN, Double.NaN, Double.NaN); + return; + } + final double lolo, lo, hi, hihi; + if (model_widget.propAlarmLimitsFromPV().getValue()) + { + final Display displayInfo = Display.displayOf(vtype); + if (displayInfo != null) + { + final Range minor = displayInfo.getWarningRange(); + final Range major = displayInfo.getAlarmRange(); + lo = minor.getMinimum(); + hi = minor.getMaximum(); + lolo = major.getMinimum(); + hihi = major.getMaximum(); + } + else + lolo = lo = hi = hihi = Double.NaN; + } + else + { + lolo = model_widget.propLevelLoLo().getValue(); + lo = model_widget.propLevelLow().getValue(); + hi = model_widget.propLevelHigh().getValue(); + hihi = model_widget.propLevelHiHi().getValue(); + } + tank.setLimits(lolo, lo, hi, hihi); + tank.setLimitsFromPV(model_widget.propAlarmLimitsFromPV().getValue()); + } + + /** @return whether this widget is currently in horizontal orientation */ + protected abstract boolean isHorizontal(); + + /** Swaps width and height in the editor (so the widget visually rotates + * rather than stretching) and triggers a look update. */ + protected void orientationChanged(final WidgetProperty prop, + final Boolean old, final Boolean horizontal) + { + if (toolkit.isEditMode()) + { + final int w = model_widget.propWidth().getValue(); + final int h = model_widget.propHeight().getValue(); + model_widget.propWidth().setValue(h); + model_widget.propHeight().setValue(w); + } + dirtyLook.mark(); + toolkit.scheduleUpdate(this); + } + + /** Push the current widget-specific appearance properties to the tank. + * Called from {@link #updateChanges()} after size and orientation + * have been applied. */ + protected abstract void applyLookToTank(); + + @Override + public void updateChanges() + { + super.updateChanges(); + if (dirtyLook.checkAndClear()) + { + final double width = model_widget.propWidth().getValue(); + final double height = model_widget.propHeight().getValue(); + + // RTTank renders vertically; rotate 90 degrees clockwise for horizontal bars. + if (isHorizontal()) + { + tank.getTransforms().setAll(new Translate(width, 0), + new Rotate(90, 0, 0)); + wasTransformed = true; + tank.setWidth(height); + tank.setHeight(width); + } + else + { + if (wasTransformed) + tank.getTransforms().clear(); + wasTransformed = false; + tank.setWidth(width); + tank.setHeight(height); + } + jfx_node.setPrefSize(width, height); + + applyLookToTank(); + tank.setAlarmColors( + JFXUtil.convert(model_widget.propMinorAlarmColor().getValue()), + JFXUtil.convert(model_widget.propMajorAlarmColor().getValue())); + } + } +} diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/TankRepresentation.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/TankRepresentation.java index 8e3d373914..e3b7df6f68 100644 --- a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/TankRepresentation.java +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/TankRepresentation.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2015-2023 Oak Ridge National Laboratory. + * Copyright (c) 2015-2026 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -7,52 +7,31 @@ *******************************************************************************/ package org.csstudio.display.builder.representation.javafx.widgets; -import java.util.concurrent.TimeUnit; - -import org.csstudio.display.builder.model.DirtyFlag; -import org.csstudio.display.builder.model.UntypedWidgetPropertyListener; -import org.csstudio.display.builder.model.WidgetProperty; -import org.csstudio.display.builder.model.WidgetPropertyListener; -import org.csstudio.display.builder.model.util.VTypeUtil; import org.csstudio.display.builder.model.widgets.TankWidget; -import org.csstudio.display.builder.representation.Preferences; import org.csstudio.display.builder.representation.javafx.JFXUtil; -import org.csstudio.javafx.rtplot.RTTank; -import org.epics.util.stats.Range; -import org.epics.vtype.Display; -import org.epics.vtype.VType; - -import javafx.scene.layout.Pane; -import javafx.scene.transform.Rotate; -import javafx.scene.transform.Translate; -/** Creates JavaFX item for model widget +/** Creates JavaFX item for the Tank widget. + * + *

    All shared RTTank wiring (value updates, alarm limits, + * orientation handling) lives in {@link RTScaledWidgetRepresentation}. + * This class contributes only the Tank-specific appearance properties: + * background, foreground, fill and empty colors. + * * @author Kay Kasemir * @author Heredie Delvalle — CLS, alarm limits, dual scale, * format/precision wiring */ -public class TankRepresentation extends RegionBaseRepresentation +public class TankRepresentation extends RTScaledWidgetRepresentation { - private final DirtyFlag dirty_look = new DirtyFlag(); - private final UntypedWidgetPropertyListener lookListener = this::lookChanged; - private final UntypedWidgetPropertyListener valueListener = this::valueChanged; - private final UntypedWidgetPropertyListener limitsListener = this::limitsChanged; - private final WidgetPropertyListener orientationChangedListener = this::orientationChanged; - - private volatile RTTank tank; - @Override - public Pane createJFXNode() throws Exception + protected boolean isHorizontal() { - tank = new RTTank(); - tank.setUpdateThrottle(Preferences.image_update_delay, TimeUnit.MILLISECONDS); - return new Pane(tank); + return model_widget.propHorizontal().getValue(); } @Override - protected void registerListeners() + protected void registerLookListeners() { - super.registerListeners(); model_widget.propWidth().addUntypedPropertyListener(lookListener); model_widget.propHeight().addUntypedPropertyListener(lookListener); model_widget.propFont().addUntypedPropertyListener(lookListener); @@ -62,36 +41,18 @@ protected void registerListeners() model_widget.propEmptyColor().addUntypedPropertyListener(lookListener); model_widget.propScaleVisible().addUntypedPropertyListener(lookListener); model_widget.propShowMinorTicks().addUntypedPropertyListener(lookListener); + model_widget.propShowScaleLabels().addUntypedPropertyListener(lookListener); model_widget.propPerpendicularTickLabels().addUntypedPropertyListener(lookListener); model_widget.propFormat().addUntypedPropertyListener(lookListener); model_widget.propPrecision().addUntypedPropertyListener(lookListener); - model_widget.propMinorAlarmColor().addUntypedPropertyListener(lookListener); - model_widget.propMajorAlarmColor().addUntypedPropertyListener(lookListener); model_widget.propOppositeScaleVisible().addUntypedPropertyListener(lookListener); model_widget.propBorderWidth().addUntypedPropertyListener(lookListener); model_widget.propLogScale().addUntypedPropertyListener(lookListener); - - // Range and fill-level; need re-evaluation on every PV sample - model_widget.propLimitsFromPV().addUntypedPropertyListener(valueListener); - model_widget.propMinimum().addUntypedPropertyListener(valueListener); - model_widget.propMaximum().addUntypedPropertyListener(valueListener); - model_widget.runtimePropValue().addUntypedPropertyListener(valueListener); - // Alarm limits; only need re-evaluation when limit properties change. - // When alarm_limits_from_pv=true, valueChanged() calls applyAlarmLimits() too. - model_widget.propShowAlarmLimits().addUntypedPropertyListener(limitsListener); - model_widget.propAlarmLimitsFromPV().addUntypedPropertyListener(limitsListener); - model_widget.propLevelLoLo().addUntypedPropertyListener(limitsListener); - model_widget.propLevelLow().addUntypedPropertyListener(limitsListener); - model_widget.propLevelHigh().addUntypedPropertyListener(limitsListener); - model_widget.propLevelHiHi().addUntypedPropertyListener(limitsListener); model_widget.propHorizontal().addPropertyListener(orientationChangedListener); - // Initial apply — order matters: range first, then limits, then value - valueChanged(null, null, null); - limitsChanged(null, null, null); } @Override - protected void unregisterListeners() + protected void unregisterLookListeners() { model_widget.propWidth().removePropertyListener(lookListener); model_widget.propHeight().removePropertyListener(lookListener); @@ -102,171 +63,32 @@ protected void unregisterListeners() model_widget.propEmptyColor().removePropertyListener(lookListener); model_widget.propScaleVisible().removePropertyListener(lookListener); model_widget.propShowMinorTicks().removePropertyListener(lookListener); + model_widget.propShowScaleLabels().removePropertyListener(lookListener); model_widget.propPerpendicularTickLabels().removePropertyListener(lookListener); model_widget.propFormat().removePropertyListener(lookListener); model_widget.propPrecision().removePropertyListener(lookListener); - model_widget.propMinorAlarmColor().removePropertyListener(lookListener); - model_widget.propMajorAlarmColor().removePropertyListener(lookListener); model_widget.propOppositeScaleVisible().removePropertyListener(lookListener); model_widget.propBorderWidth().removePropertyListener(lookListener); model_widget.propLogScale().removePropertyListener(lookListener); - - model_widget.propLimitsFromPV().removePropertyListener(valueListener); - model_widget.propMinimum().removePropertyListener(valueListener); - model_widget.propMaximum().removePropertyListener(valueListener); - model_widget.runtimePropValue().removePropertyListener(valueListener); - model_widget.propShowAlarmLimits().removePropertyListener(limitsListener); - model_widget.propAlarmLimitsFromPV().removePropertyListener(limitsListener); - model_widget.propLevelLoLo().removePropertyListener(limitsListener); - model_widget.propLevelLow().removePropertyListener(limitsListener); - model_widget.propLevelHigh().removePropertyListener(limitsListener); - model_widget.propLevelHiHi().removePropertyListener(limitsListener); model_widget.propHorizontal().removePropertyListener(orientationChangedListener); - super.unregisterListeners(); } - private void lookChanged(final WidgetProperty property, final Object old_value, final Object new_value) - { - dirty_look.mark(); - toolkit.scheduleUpdate(this); - } - - /** Update the display range and fill level. Called on every PV value change. - * Alarm limits from PV metadata are also refreshed here (the metadata is - * carried inside the VType on every update). Manually-configured limits - * are managed exclusively by {@link #limitsChanged}. - */ - private void valueChanged(final WidgetProperty property, final Object old_value, final Object new_value) - { - final VType vtype = model_widget.runtimePropValue().getValue(); - - double min_val = model_widget.propMinimum().getValue(); - double max_val = model_widget.propMaximum().getValue(); - if (model_widget.propLimitsFromPV().getValue()) - { - final Display display_info = Display.displayOf(vtype); - if (display_info != null && display_info.getDisplayRange().isFinite()) - { - min_val = display_info.getDisplayRange().getMinimum(); - max_val = display_info.getDisplayRange().getMaximum(); - } - } - tank.setRange(min_val, max_val); - - // Alarm metadata is embedded in the VType, so re-check it on every update. - // When using widget-configured limits, limitsChanged() handles updates instead. - if (model_widget.propAlarmLimitsFromPV().getValue()) - applyAlarmLimits(vtype); - - final double value = toolkit.isEditMode() - ? (min_val + max_val) / 2 - : VTypeUtil.getValueNumber(vtype).doubleValue(); - tank.setValue(value); - } - - /** Re-apply alarm limit lines. Called when any limit property changes. - * Also invoked from {@link #valueChanged} when limits come from the PV. - */ - private void limitsChanged(final WidgetProperty property, final Object old_value, final Object new_value) - { - applyAlarmLimits(model_widget.runtimePropValue().getValue()); - } - - /** Push the current alarm limits to the tank, reading from PV metadata or - * widget properties depending on {@code alarm_limits_from_pv}. - * Clears all limit lines when {@code show_alarm_limits} is {@code false}. - */ - private void applyAlarmLimits(final VType vtype) - { - if (!model_widget.propShowAlarmLimits().getValue()) - { - tank.setLimits(Double.NaN, Double.NaN, Double.NaN, Double.NaN); - return; - } - final double lolo, lo, hi, hihi; - if (model_widget.propAlarmLimitsFromPV().getValue()) - { - final Display display_info = Display.displayOf(vtype); - if (display_info != null) - { - final Range minor = display_info.getWarningRange(); - final Range major = display_info.getAlarmRange(); - lo = minor.getMinimum(); - hi = minor.getMaximum(); - lolo = major.getMinimum(); - hihi = major.getMaximum(); - } - else - { // PV connected but no metadata yet — show nothing - lolo = lo = hi = hihi = Double.NaN; - } - } - else - { - lolo = model_widget.propLevelLoLo().getValue(); - lo = model_widget.propLevelLow().getValue(); - hi = model_widget.propLevelHigh().getValue(); - hihi = model_widget.propLevelHiHi().getValue(); - } - tank.setLimits(lolo, lo, hi, hihi); - tank.setLimitsFromPV(model_widget.propAlarmLimitsFromPV().getValue()); - } - - private void orientationChanged(final WidgetProperty prop, final Boolean old, final Boolean horizontal) - { - if (toolkit.isEditMode()) - { // Swap width <-> height so widget basically rotates - final int w = model_widget.propWidth().getValue(); - final int h = model_widget.propHeight().getValue(); - model_widget.propWidth().setValue(h); - model_widget.propHeight().setValue(w); - } - lookChanged(prop, old, horizontal); - } - - /** Track if we ever set transformations because just 'clearing' would otherwise allocate them */ - private boolean was_transformed = false; - @Override - public void updateChanges() + protected void applyLookToTank() { - super.updateChanges(); - if (dirty_look.checkAndClear()) - { - double width = model_widget.propWidth().getValue(); - double height = model_widget.propHeight().getValue(); - if (model_widget.propHorizontal().getValue()) - { - tank.getTransforms().setAll(new Translate(width, 0), - new Rotate(90, 0, 0)); - was_transformed = true; - tank.setWidth(height); - tank.setHeight(width); - } - else - { - if (was_transformed) - tank.getTransforms().clear(); - tank.setWidth(width); - tank.setHeight(height); - } - jfx_node.setPrefSize(width, height); - tank.setFont(JFXUtil.convert(model_widget.propFont().getValue())); - tank.setBackground(JFXUtil.convert(model_widget.propBackground().getValue())); - tank.setForeground(JFXUtil.convert(model_widget.propForeground().getValue())); - tank.setFillColor(JFXUtil.convert(model_widget.propFillColor().getValue())); - tank.setEmptyColor(JFXUtil.convert(model_widget.propEmptyColor().getValue())); - tank.setScaleVisible(model_widget.propScaleVisible().getValue()); - tank.setShowMinorTicks(model_widget.propShowMinorTicks().getValue()); - tank.setPerpendicularTickLabels(model_widget.propPerpendicularTickLabels().getValue()); - tank.setLogScale(model_widget.propLogScale().getValue()); - tank.setLabelFormat(model_widget.propFormat().getValue(), - model_widget.propPrecision().getValue()); - tank.setAlarmColors( - JFXUtil.convert(model_widget.propMinorAlarmColor().getValue()), - JFXUtil.convert(model_widget.propMajorAlarmColor().getValue())); - tank.setRightScaleVisible(model_widget.propOppositeScaleVisible().getValue()); - tank.setBorderWidth(model_widget.propBorderWidth().getValue()); - } + tank.setFont(JFXUtil.convert(model_widget.propFont().getValue())); + tank.setBackground(JFXUtil.convert(model_widget.propBackground().getValue())); + tank.setForeground(JFXUtil.convert(model_widget.propForeground().getValue())); + tank.setFillColor(JFXUtil.convert(model_widget.propFillColor().getValue())); + tank.setEmptyColor(JFXUtil.convert(model_widget.propEmptyColor().getValue())); + tank.setScaleVisible(model_widget.propScaleVisible().getValue()); + tank.setShowMinorTicks(model_widget.propShowMinorTicks().getValue()); + tank.setScaleLabelsVisible(model_widget.propShowScaleLabels().getValue()); + tank.setPerpendicularTickLabels(model_widget.propPerpendicularTickLabels().getValue()); + tank.setLogScale(model_widget.propLogScale().getValue()); + tank.setLabelFormat(model_widget.propFormat().getValue(), + model_widget.propPrecision().getValue()); + tank.setRightScaleVisible(model_widget.propOppositeScaleVisible().getValue()); + tank.setBorderWidth(model_widget.propBorderWidth().getValue()); } } diff --git a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java index 8c294c7b05..7ed55b8b34 100644 --- a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java +++ b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java @@ -284,6 +284,17 @@ public void setShowMinorTicks(final boolean show) requestUpdate(); } + /** Show or hide the tick labels while keeping the tick marks. + * Stacked widgets can then share one labelled scale: only the first + * shows labels, the others show aligned tick marks. + * @param visible {@code true} (default) for labels, {@code false} for ticks only */ + public void setScaleLabelsVisible(final boolean visible) + { + // The axes request layout and refresh themselves when this changes + scale.setScaleLabelsVisible(visible); + right_scale.setScaleLabelsVisible(visible); + } + /** Configure the number format used for scale tick labels. * @param format Display format; {@code null} or {@link ScaleFormat#DEFAULT} restores automatic formatting. * @param precision Number of decimal places; clamped to [0, 15]. diff --git a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/YAxisImpl.java b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/YAxisImpl.java index 109ad205c5..c6735dff96 100644 --- a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/YAxisImpl.java +++ b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/YAxisImpl.java @@ -60,6 +60,11 @@ public class YAxisImpl> extends NumericAxis impl /** Show on right side? */ private volatile boolean is_right = false; + /** When {@code false}, only the tick marks are drawn: no tick labels + * and no axis label. Stacked widgets can then share one labelled scale. + * Read on the render thread, written from the JavaFX thread. */ + private volatile boolean labelsVisible = true; + /** When {@code true}, rotated tick labels always use the 'up' direction * (bottom-to-top) regardless of {@link #is_right}. This keeps the text * orientation of a right-side scale identical to a left-side scale. @@ -161,6 +166,20 @@ public void setForceTextUp(final boolean force) force_text_up = force; } + /** Show or hide the tick labels and the axis label while keeping the + * tick marks. Tick positions do not change, so stacked widgets can + * share one labelled scale. + * @param show {@code true} (default) for labels, {@code false} for ticks only + */ + public void setScaleLabelsVisible(final boolean show) + { + if (labelsVisible == show) + return; + labelsVisible = show; + requestLayout(); + requestRefresh(); + } + /** Add trace to axis * @param trace {@link Trace} * @throws IllegalArgumentException if trace already on axis @@ -210,6 +229,11 @@ public int getDesiredPixelSize(final Rectangle region, final Graphics2D gc) return 0; this.region = region; + + // Ticks only: the tick marks plus the axis line + if (!labelsVisible) + return TICK_LENGTH + 1; + gc.setFont(label_font); FontMetrics metrics = gc.getFontMetrics(); @@ -392,7 +416,9 @@ public void paint(final Graphics2D gc, final Rectangle plot_bounds) // Skip the visibility pass when LogTicks already thinned the labeled set: // a second greedy pass would destroy the intentional symmetric spacing. final boolean skipVisibility = (ticks instanceof LogTicks) && ((LogTicks) ticks).isThinned(); - final boolean[] showLabel = skipVisibility + final boolean[] showLabel = !labelsVisible + ? new boolean[majorTicks.size()] + : skipVisibility ? allLabeled(majorTicks) : computeTickLabelVisibility(majorTicks, gc.getFontMetrics()); @@ -428,8 +454,11 @@ public void paint(final Graphics2D gc, final Rectangle plot_bounds) gc.setColor(old_fg); gc.setBackground(old_bg); - gc.setFont(label_font); - paintLabels(gc); + if (labelsVisible) + { + gc.setFont(label_font); + paintLabels(gc); + } } protected void paintLabels(final Graphics2D gc) diff --git a/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/internal/YAxisImplTest.java b/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/internal/YAxisImplTest.java new file mode 100644 index 0000000000..9a4723519e --- /dev/null +++ b/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/internal/YAxisImplTest.java @@ -0,0 +1,55 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ +package org.csstudio.javafx.rtplot.internal; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; + +import org.junit.jupiter.api.Test; + +/** JUnit test for {@link YAxisImpl} */ +public class YAxisImplTest +{ + /** Hiding the tick labels must not move the ticks: the axis keeps the + * same room at both ends, so a ticks-only scale lines up with a + * labelled one */ + @Test + public void testTicksOnlyKeepsPixelGaps() + { + final PlotPartListener listener = new PlotPartListener() + { + @Override + public void layoutPlotPart(final PlotPart plotPart) + { + } + + @Override + public void refreshPlotPart(final PlotPart plotPart) + { + } + }; + final YAxisImpl axis = new YAxisImpl<>("", listener); + axis.setValueRange(0.0, 100.0); + axis.setBounds(0, 0, 40, 300); + final Graphics2D gc = new BufferedImage(40, 300, BufferedImage.TYPE_INT_ARGB).createGraphics(); + axis.computeTicks(gc); + for (boolean perpendicular : new boolean[] { false, true }) + { + axis.setPerpendicularTickLabels(perpendicular); + axis.setScaleLabelsVisible(true); + final int[] labelled = axis.getPixelGaps(gc); + assertTrue(labelled[0] > 0 && labelled[1] > 0); + axis.setScaleLabelsVisible(false); + assertArrayEquals(labelled, axis.getPixelGaps(gc)); + } + gc.dispose(); + } +} From 158f125f8f5a5522cc0739d48bc207760620357c Mon Sep 17 00:00:00 2001 From: Emilio Heredia Date: Fri, 18 Sep 2026 13:46:02 -0600 Subject: [PATCH 2/7] feat(display): make ProgressBarWidget a ScaledPVWidget Move the progress bar onto the ScaledPVWidget base class that the Tank already uses, so it carries the same range, scale and alarm limit properties, plus an inner padding. The scale look properties (font, foreground and fill color, log scale, scale visibility, tick options, border width) now live in ScaledPVWidget with a defineScaleLookProperties() helper, and the Tank uses the same fields in its own order. The border keeps the Tank's 'tank_border_width' name for all scaled widgets: legacy BOY files carry a 'border_width' element for every widget, which must not become a bar border. The stock renderer ignores the additions, and existing .bob files keep loading unchanged since the pre-existing properties keep their names. The BOY importer picks up 'show_scale', 'scale_font' and the 'level_hi' and 'level_lo' names that differ from ours. ProgressBarWidgetUnitTest covers defaults, legacy .bob and BOY files, the XML round trip and the list of renderer-only properties. --- .../display/builder/model/Messages.java | 1 + .../model/widgets/ProgressBarWidget.java | 113 +++++---- .../builder/model/widgets/ScaledPVWidget.java | 94 +++++++ .../builder/model/widgets/TankWidget.java | 94 +------ .../display/builder/model/messages.properties | 1 + .../builder/model/messages_fr.properties | 1 + .../widgets/ProgressBarWidgetUnitTest.java | 230 ++++++++++++++++++ .../model/widgets/TankWidgetUnitTest.java | 12 + 8 files changed, 406 insertions(+), 140 deletions(-) create mode 100644 app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/ProgressBarWidgetUnitTest.java diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java index 27bdbcc778..9a62e6a502 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java @@ -241,6 +241,7 @@ public class Messages WidgetProperties_HourTickMarkColor, WidgetProperties_HourTickMarkVisible, WidgetProperties_Increment, + WidgetProperties_InnerPadding, WidgetProperties_InitialIndex, WidgetProperties_Insets, WidgetProperties_Interactive, diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ProgressBarWidget.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ProgressBarWidget.java index 193cb01ecf..89f936b81a 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ProgressBarWidget.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ProgressBarWidget.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2015-2022 Oak Ridge National Laboratory. + * Copyright (c) 2015-2026 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -8,15 +8,11 @@ package org.csstudio.display.builder.model.widgets; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propBackgroundColor; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFillColor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propHorizontal; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propLimitsFromPV; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propMaximum; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propMinimum; -import static org.csstudio.display.builder.model.widgets.plots.PlotWidgetProperties.propLogscale; import java.util.Arrays; import java.util.List; +import java.util.Set; import org.csstudio.display.builder.model.Version; import org.csstudio.display.builder.model.Widget; @@ -34,12 +30,40 @@ import org.w3c.dom.Element; /** Widget that displays a progress bar + * + *

    Extends {@link ScaledPVWidget} so the bar offers the same range, + * scale and alarm limit properties as the {@link TankWidget}. + * The scale related properties only take effect with the RTTank based + * renderer, see {@link #SCALE_MODE_PROPS}. + * + *

    Existing {@code .bob} files load unchanged: {@code fill_color}, + * {@code background_color}, {@code horizontal}, {@code limits_from_pv}, + * {@code minimum}, {@code maximum} and {@code log_scale} keep their + * XML names. Older Phoebus versions ignore the new properties. + * * @author Kay Kasemir * @author Amanda Carpenter */ @SuppressWarnings("nls") -public class ProgressBarWidget extends PVWidget +public class ProgressBarWidget extends ScaledPVWidget { + /** Properties that only have an effect with the RTTank based renderer + * ({@code progressbar_scale_mode=true}). + * + *

    The property editor hides these while the stock JavaFX renderer + * is in use. Properties that both renderers honour, like the range, + * {@code horizontal}, {@code log_scale}, fill and background color, + * are not listed here. + */ + public static final Set SCALE_MODE_PROPS = Set.of( + "format", "precision", "font", "foreground_color", + "scale_visible", "show_minor_ticks", "show_scale_labels", + "opposite_scale_visible", "perpendicular_tick_labels", + "inner_padding", "tank_border_width", + "alarm_limits_from_pv", "show_alarm_limits", + "level_lolo", "level_low", "level_high", "level_hihi", + "minor_alarm_color", "major_alarm_color"); + /** Widget descriptor */ public static final WidgetDescriptor WIDGET_DESCRIPTOR = new WidgetDescriptor("progressbar", WidgetCategory.MONITOR, @@ -55,7 +79,7 @@ public Widget createWidget() } }; - /** Widget configurator to read legacy *.opi files*/ + /** Widget configurator to read legacy *.opi files */ private static class ProgressBarConfigurator extends WidgetConfigurator { public ProgressBarConfigurator(final Version xml_version) @@ -82,14 +106,18 @@ public boolean configureFromXML(final ModelReader model_reader, final Widget wid bar.propY().setValue(bar.propY().getValue() + reduce); bar.propHeight().setValue(bar.propHeight().getValue() - reduce); } - // Do use space below where BOY placed markers for the bar itself. - // In the future, there could be a scale. final Element el = XMLUtil.getChildElement(xml, "color_fillbackground"); if (el != null) bar.propBackgroundColor().readFromXML(model_reader, el); - // Create text update for the value indicator + // BOY names that differ from ours. level_hihi and level_lolo match. + readLegacyElement(model_reader, xml, "show_scale", bar.propScaleVisible()); + readLegacyElement(model_reader, xml, "scale_font", bar.propFont()); + readLegacyElement(model_reader, xml, "level_hi", bar.propLevelHigh()); + readLegacyElement(model_reader, xml, "level_lo", bar.propLevelLow()); + + // Create a companion TextUpdate widget for the BOY value label. if (XMLUtil.getChildBoolean(xml, "show_label").orElse(true)) { final Document doc = xml.getOwnerDocument(); @@ -116,6 +144,14 @@ public boolean configureFromXML(final ModelReader model_reader, final Widget wid return true; } + + private static void readLegacyElement(final ModelReader modelReader, final Element xml, + final String name, final WidgetProperty property) throws Exception + { + final Element element = XMLUtil.getChildElement(xml, name); + if (element != null) + property.readFromXML(modelReader, element); + } } @Override @@ -125,37 +161,24 @@ public WidgetConfigurator getConfigurator(final Version persisted_version) return new ProgressBarConfigurator(persisted_version); } - private volatile WidgetProperty limits_from_pv; - private volatile WidgetProperty minimum; - private volatile WidgetProperty maximum; - private volatile WidgetProperty log_scale; - private volatile WidgetProperty fill_color; private volatile WidgetProperty background_color; - private volatile WidgetProperty horizontal; + private volatile WidgetProperty horizontal; + private volatile WidgetProperty innerPadding; /** Constructor */ public ProgressBarWidget() { - super(WIDGET_DESCRIPTOR.getType()); + super(WIDGET_DESCRIPTOR.getType(), 100, 20); } @Override protected void defineProperties(final List> properties) { super.defineProperties(properties); - properties.add(fill_color = propFillColor.createProperty(this, new WidgetColor(60, 255, 60))); + defineScaleLookProperties(properties, false, false); properties.add(background_color = propBackgroundColor.createProperty(this, new WidgetColor(250, 250, 250))); - properties.add(limits_from_pv = propLimitsFromPV.createProperty(this, true)); - properties.add(minimum = propMinimum.createProperty(this, 0.0)); - properties.add(maximum = propMaximum.createProperty(this, 100.0)); - properties.add(log_scale = propLogscale.createProperty(this, false)); - properties.add(horizontal = propHorizontal.createProperty(this, true)); - } - - /** @return 'fill_color' property */ - public WidgetProperty propFillColor() - { - return fill_color; + properties.add(horizontal = propHorizontal.createProperty(this, true)); + properties.add(innerPadding = propInnerPadding.createProperty(this, 3)); } /** @return 'background_color' property */ @@ -164,33 +187,15 @@ public WidgetProperty propBackgroundColor() return background_color; } - /** @return 'limits_from_pv' property */ - public WidgetProperty propLimitsFromPV() - { - return limits_from_pv; - } - - /** @return 'minimum' property */ - public WidgetProperty propMinimum() - { - return minimum; - } - - /** @return 'maximum' property */ - public WidgetProperty propMaximum() - { - return maximum; - } - - /** @return 'log_scale' property */ - public WidgetProperty propLogScale() - { - return log_scale; - } - /** @return 'horizontal' property */ public WidgetProperty propHorizontal() { return horizontal; } + + /** @return 'inner_padding' property */ + public WidgetProperty propInnerPadding() + { + return innerPadding; + } } diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ScaledPVWidget.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ScaledPVWidget.java index bdfd7af1b1..8f87c62446 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ScaledPVWidget.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ScaledPVWidget.java @@ -11,9 +11,13 @@ import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newColorPropertyDescriptor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newDoublePropertyDescriptor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newIntegerPropertyDescriptor; +import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFillColor; +import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFont; +import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propForegroundColor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propLimitsFromPV; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propMaximum; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propMinimum; +import static org.csstudio.display.builder.model.widgets.plots.PlotWidgetProperties.propLogscale; import java.util.List; @@ -22,7 +26,10 @@ import org.csstudio.display.builder.model.WidgetProperty; import org.csstudio.display.builder.model.WidgetPropertyCategory; import org.csstudio.display.builder.model.WidgetPropertyDescriptor; +import org.csstudio.display.builder.model.persist.NamedWidgetFonts; +import org.csstudio.display.builder.model.persist.WidgetFontService; import org.csstudio.display.builder.model.properties.EnumWidgetProperty; +import org.csstudio.display.builder.model.properties.WidgetFont; import org.phoebus.ui.color.NamedWidgetColors; import org.phoebus.ui.color.WidgetColor; import org.phoebus.ui.color.WidgetColorService; @@ -150,6 +157,19 @@ public EnumWidgetProperty createProperty(final Widget widget, newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "show_scale_labels", Messages.WidgetProperties_ShowScaleLabels); + /** 'tank_border_width': width in pixels of the border drawn around the + * body (0..5). The name avoids the 'border_width' element that legacy + * BOY files carry for every widget. */ + public static final WidgetPropertyDescriptor propTankBorderWidth = + newIntegerPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "tank_border_width", + Messages.WidgetProperties_BorderWidth, 0, 5); + + /** 'inner_padding': padding in pixels (0..20). For the progress bar, the gap + * between the track and the fill */ + public static final WidgetPropertyDescriptor propInnerPadding = + newIntegerPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "inner_padding", + Messages.WidgetProperties_InnerPadding, 0, 20); + // ---- Instance fields ------------------------------------------------ private volatile WidgetProperty format; @@ -166,6 +186,19 @@ public EnumWidgetProperty createProperty(final Widget widget, private volatile WidgetProperty minor_alarm_color; private volatile WidgetProperty major_alarm_color; + // Scale look. Subclasses create these in defineScaleLookProperties(), + // or one by one when they need a different order or defaults. + protected volatile WidgetProperty font; + protected volatile WidgetProperty foreground; + protected volatile WidgetProperty fillColor; + protected volatile WidgetProperty logScale; + protected volatile WidgetProperty scaleVisible; + protected volatile WidgetProperty showMinorTicks; + protected volatile WidgetProperty showScaleLabels; + protected volatile WidgetProperty oppositeScaleVisible; + protected volatile WidgetProperty perpendicularTickLabels; + protected volatile WidgetProperty borderWidth; + protected ScaledPVWidget(final String type, final int default_width, final int default_height) { super(type, default_width, default_height); @@ -203,6 +236,67 @@ protected void defineProperties(final List> properties) WidgetColorService.getColor(NamedWidgetColors.ALARM_MAJOR))); } + /** Define the scale look properties: font, foreground and fill color, + * log scale, scale visibility and tick options, border width. + * @param properties Property list of the widget + * @param showScale Default for 'scale_visible' + * @param perpendicularLabels Default for 'perpendicular_tick_labels' + */ + protected void defineScaleLookProperties(final List> properties, + final boolean showScale, + final boolean perpendicularLabels) + { + properties.add(font = propFont.createProperty(this, WidgetFontService.get(NamedWidgetFonts.DEFAULT))); + properties.add(foreground = propForegroundColor.createProperty(this, WidgetColorService.getColor(NamedWidgetColors.TEXT))); + properties.add(fillColor = propFillColor.createProperty(this, new WidgetColor(60, 255, 60))); + properties.add(logScale = propLogscale.createProperty(this, false)); + properties.add(scaleVisible = propScaleVisible.createProperty(this, showScale)); + properties.add(showMinorTicks = propShowMinorTicks.createProperty(this, true)); + properties.add(showScaleLabels = propShowScaleLabels.createProperty(this, true)); + properties.add(oppositeScaleVisible = propOppositeScaleVisible.createProperty(this, false)); + properties.add(perpendicularTickLabels = propPerpendicularTickLabels.createProperty(this, perpendicularLabels)); + properties.add(borderWidth = propTankBorderWidth.createProperty(this, 0)); + } + + /** @return The scale look properties, for representations that listen to all of them */ + public List> getScaleLookProperties() + { + return List.of(font, foreground, fillColor, logScale, + scaleVisible, showMinorTicks, showScaleLabels, + oppositeScaleVisible, perpendicularTickLabels, + borderWidth); + } + + /** @return 'font' property */ + public WidgetProperty propFont() { return font; } + + /** @return 'foreground_color' property */ + public WidgetProperty propForeground() { return foreground; } + + /** @return 'fill_color' property */ + public WidgetProperty propFillColor() { return fillColor; } + + /** @return 'log_scale' property */ + public WidgetProperty propLogScale() { return logScale; } + + /** @return 'scale_visible' property */ + public WidgetProperty propScaleVisible() { return scaleVisible; } + + /** @return 'show_minor_ticks' property */ + public WidgetProperty propShowMinorTicks() { return showMinorTicks; } + + /** @return 'show_scale_labels' property */ + public WidgetProperty propShowScaleLabels() { return showScaleLabels; } + + /** @return 'opposite_scale_visible' property */ + public WidgetProperty propOppositeScaleVisible() { return oppositeScaleVisible; } + + /** @return 'perpendicular_tick_labels' property */ + public WidgetProperty propPerpendicularTickLabels() { return perpendicularTickLabels; } + + /** @return 'tank_border_width' property (0 = no border) */ + public WidgetProperty propBorderWidth() { return borderWidth; } + /** @return 'format' property (scale label format) */ public WidgetProperty propFormat() { return format; } diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/TankWidget.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/TankWidget.java index b947edb2a5..0df3727b7d 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/TankWidget.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/TankWidget.java @@ -8,7 +8,6 @@ package org.csstudio.display.builder.model.widgets; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newColorPropertyDescriptor; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newIntegerPropertyDescriptor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propBackgroundColor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFillColor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFont; @@ -31,7 +30,6 @@ import org.csstudio.display.builder.model.persist.ModelReader; import org.csstudio.display.builder.model.persist.NamedWidgetFonts; import org.csstudio.display.builder.model.persist.WidgetFontService; -import org.csstudio.display.builder.model.properties.WidgetFont; import org.phoebus.ui.color.NamedWidgetColors; import org.phoebus.ui.color.WidgetColor; import org.phoebus.ui.color.WidgetColorService; @@ -73,13 +71,6 @@ public Widget createWidget() } }; - /** 'tank_border_width' — width in pixels of the border drawn around the - * tank body; 0 (default) means no border, preserving the original look. - */ - public static final WidgetPropertyDescriptor propTankBorderWidth = - newIntegerPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "tank_border_width", - Messages.WidgetProperties_BorderWidth, 0, 5); - /** 'empty_color' */ public static final WidgetPropertyDescriptor propEmptyColor = newColorPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "empty_color", Messages.WidgetProperties_EmptyColor); @@ -113,7 +104,7 @@ public boolean configureFromXML(final ModelReader model_reader, final Widget wid element = XMLUtil.getChildElement(xml, "show_scale"); if (element != null) - tank.scale_visible.readFromXML(model_reader, element); + tank.propScaleVisible().readFromXML(model_reader, element); if (XMLUtil.getChildBoolean(xml, "show_markers").orElse(true) && (XMLUtil.getChildBoolean(xml, "show_hi").orElse(true) || @@ -140,19 +131,9 @@ public WidgetConfigurator getConfigurator(final Version persisted_version) return new CustomConfigurator(persisted_version); } - private volatile WidgetProperty font; - private volatile WidgetProperty foreground; private volatile WidgetProperty background; - private volatile WidgetProperty fill_color; private volatile WidgetProperty empty_color; - private volatile WidgetProperty scale_visible; - private volatile WidgetProperty show_minor_ticks; - private volatile WidgetProperty showScaleLabels; - private volatile WidgetProperty perpendicular_tick_labels; - private volatile WidgetProperty opposite_scale_visible; - private volatile WidgetProperty log_scale; private volatile WidgetProperty horizontal; - private volatile WidgetProperty border_width_prop; /** Constructor */ @@ -168,16 +149,16 @@ protected void defineProperties(final List> properties) properties.add(font = propFont.createProperty(this, WidgetFontService.get(NamedWidgetFonts.DEFAULT))); properties.add(foreground = propForegroundColor.createProperty(this, WidgetColorService.getColor(NamedWidgetColors.TEXT))); properties.add(background = propBackgroundColor.createProperty(this, WidgetColorService.getColor(NamedWidgetColors.READ_BACKGROUND))); - properties.add(fill_color = propFillColor.createProperty(this, new WidgetColor(0, 0, 255))); + properties.add(fillColor = propFillColor.createProperty(this, new WidgetColor(0, 0, 255))); properties.add(empty_color = propEmptyColor.createProperty(this, new WidgetColor(192, 192, 192))); - properties.add(scale_visible = propScaleVisible.createProperty(this, true)); - properties.add(opposite_scale_visible = propOppositeScaleVisible.createProperty(this, false)); - properties.add(show_minor_ticks = propShowMinorTicks.createProperty(this, true)); + properties.add(scaleVisible = propScaleVisible.createProperty(this, true)); + properties.add(oppositeScaleVisible = propOppositeScaleVisible.createProperty(this, false)); + properties.add(showMinorTicks = propShowMinorTicks.createProperty(this, true)); properties.add(showScaleLabels = propShowScaleLabels.createProperty(this, true)); - properties.add(perpendicular_tick_labels = propPerpendicularTickLabels.createProperty(this, false)); - properties.add(log_scale = propLogscale.createProperty(this, false)); + properties.add(perpendicularTickLabels = propPerpendicularTickLabels.createProperty(this, false)); + properties.add(logScale = propLogscale.createProperty(this, false)); properties.add(horizontal = propHorizontal.createProperty(this, false)); - properties.add(border_width_prop = propTankBorderWidth.createProperty(this, 0)); + properties.add(borderWidth = propTankBorderWidth.createProperty(this, 0)); } @Override @@ -189,81 +170,22 @@ public WidgetProperty getProperty(String name) throws IllegalArgumentExceptio return super.getProperty(name); } - /** @return 'font' property */ - public WidgetProperty propFont() - { - return font; - } - - /** @return 'foreground_color' property */ - public WidgetProperty propForeground() - { - return foreground; - } - /** @return 'background_color' property */ public WidgetProperty propBackground() { return background; } - /** @return 'fill_color' property */ - public WidgetProperty propFillColor() - { - return fill_color; - } - /** @return 'empty_color' property */ public WidgetProperty propEmptyColor() { return empty_color; } - /** @return 'scale_visible' property */ - public WidgetProperty propScaleVisible() - { - return scale_visible; - } - - /** @return 'show_minor_ticks' property */ - public WidgetProperty propShowMinorTicks() - { - return show_minor_ticks; - } - - /** @return 'show_scale_labels' property */ - public WidgetProperty propShowScaleLabels() - { - return showScaleLabels; - } - - /** @return 'perpendicular_tick_labels' property */ - public WidgetProperty propPerpendicularTickLabels() - { - return perpendicular_tick_labels; - } - - /** @return 'opposite_scale_visible' property */ - public WidgetProperty propOppositeScaleVisible() - { - return opposite_scale_visible; - } - - /** @return 'log_scale' property */ - public WidgetProperty propLogScale() - { - return log_scale; - } - /** @return 'horizontal' property */ public WidgetProperty propHorizontal() { return horizontal; } - /** @return 'border_width' property (0 = no border) */ - public WidgetProperty propBorderWidth() - { - return border_width_prop; - } } diff --git a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties index 586c0cf6c7..850272fa44 100644 --- a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties +++ b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties @@ -227,6 +227,7 @@ WidgetProperties_HourColor=Hour Color WidgetProperties_HourTickMarkColor=Hour Tick Mark Color WidgetProperties_HourTickMarkVisible=Hour Tick Mark Visible WidgetProperties_Increment=Increment +WidgetProperties_InnerPadding=Inner Padding WidgetProperties_InitialIndex=Initial Index WidgetProperties_Insets=Insets WidgetProperties_Interactive=Interactive diff --git a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties index 3bc18b6855..1b8ddb720b 100644 --- a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties +++ b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties @@ -227,6 +227,7 @@ WidgetProperties_HourColor=Couleur de l'heure WidgetProperties_HourTickMarkColor=Couleur des graduations horaires WidgetProperties_HourTickMarkVisible=Graduations horaires visibles WidgetProperties_Increment=Incrément +WidgetProperties_InnerPadding=Marge intérieure WidgetProperties_InitialIndex=Index initial WidgetProperties_Insets=Marges WidgetProperties_Interactive=Interactif diff --git a/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/ProgressBarWidgetUnitTest.java b/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/ProgressBarWidgetUnitTest.java new file mode 100644 index 0000000000..b78a99813e --- /dev/null +++ b/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/ProgressBarWidgetUnitTest.java @@ -0,0 +1,230 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.csstudio.display.builder.model.widgets; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.List; + +import org.csstudio.display.builder.model.DisplayModel; +import org.csstudio.display.builder.model.Widget; +import org.csstudio.display.builder.model.persist.ModelReader; +import org.csstudio.display.builder.model.persist.ModelWriter; +import org.junit.jupiter.api.Test; +import org.phoebus.ui.color.WidgetColor; +import org.phoebus.ui.vtype.ScaleFormat; + +/** JUnit tests for {@link ProgressBarWidget} as a {@link ScaledPVWidget} + * + *

    The progress bar must keep loading the files written before it had + * a scale, and files written now must not confuse an older Phoebus. + */ +@SuppressWarnings("nls") +public class ProgressBarWidgetUnitTest +{ + /** Defaults, in particular those that the stock renderer relies on */ + @Test + public void testDefaults() + { + final ProgressBarWidget bar = new ProgressBarWidget(); + + assertThat(bar.propWidth().getValue(), equalTo(100)); + assertThat(bar.propHeight().getValue(), equalTo(20)); + assertThat(bar.propHorizontal().getValue(), equalTo(true)); + assertThat(bar.propLimitsFromPV().getValue(), equalTo(true)); + assertThat(bar.propMinimum().getValue(), equalTo(0.0)); + assertThat(bar.propMaximum().getValue(), equalTo(100.0)); + assertThat(bar.propLogScale().getValue(), equalTo(false)); + assertThat(bar.propFillColor().getValue(), equalTo(new WidgetColor(60, 255, 60))); + assertThat(bar.propBackgroundColor().getValue(), equalTo(new WidgetColor(250, 250, 250))); + + // A bar looks like a bar until a scale is asked for + assertThat(bar.propScaleVisible().getValue(), equalTo(false)); + assertThat(bar.propShowMinorTicks().getValue(), equalTo(true)); + assertThat(bar.propShowScaleLabels().getValue(), equalTo(true)); + assertThat(bar.propOppositeScaleVisible().getValue(), equalTo(false)); + assertThat(bar.propPerpendicularTickLabels().getValue(), equalTo(false)); + assertThat(bar.propBorderWidth().getValue(), equalTo(0)); + assertThat(bar.propInnerPadding().getValue(), equalTo(3)); + assertThat(bar.propFormat().getValue(), equalTo(ScaleFormat.DEFAULT)); + assertThat(bar.propShowAlarmLimits().getValue(), equalTo(false)); + } + + /** Every name that the property panel hides for the stock renderer must be a real property */ + @Test + public void testScaleModePropertiesExist() + { + final ProgressBarWidget bar = new ProgressBarWidget(); + for (String name : ProgressBarWidget.SCALE_MODE_PROPS) + assertTrue(bar.checkProperty(name).isPresent(), "Unknown property " + name); + } + + /** A file written before the progress bar had a scale must load as before */ + @Test + public void testLegacyFileLoads() throws Exception + { + final String xml = + "\n" + + "\n" + + " \n" + + " Bar\n" + + " loc://x\n" + + " \n" + + " \n" + + " false\n" + + " 5.0\n" + + " 50.0\n" + + " true\n" + + " false\n" + + " \n" + + ""; + final ProgressBarWidget bar = (ProgressBarWidget) read(xml); + + assertThat(bar.propFillColor().getValue(), equalTo(new WidgetColor(10, 20, 30))); + assertThat(bar.propBackgroundColor().getValue(), equalTo(new WidgetColor(1, 2, 3))); + assertThat(bar.propLimitsFromPV().getValue(), equalTo(false)); + assertThat(bar.propMinimum().getValue(), equalTo(5.0)); + assertThat(bar.propMaximum().getValue(), equalTo(50.0)); + assertThat(bar.propLogScale().getValue(), equalTo(true)); + assertThat(bar.propHorizontal().getValue(), equalTo(false)); + // Scale properties keep their defaults + assertThat(bar.propScaleVisible().getValue(), equalTo(false)); + } + + /** Non-default values of the new properties survive save and load */ + @Test + public void testXmlRoundTrip() throws Exception + { + final ProgressBarWidget original = new ProgressBarWidget(); + original.propScaleVisible().setValue(true); + original.propShowScaleLabels().setValue(false); + original.propOppositeScaleVisible().setValue(true); + original.propPerpendicularTickLabels().setValue(true); + original.propBorderWidth().setValue(2); + original.propInnerPadding().setValue(7); + original.propFormat().setValue(ScaleFormat.EXPONENTIAL); + original.propPrecision().setValue(1); + original.propShowAlarmLimits().setValue(true); + original.propAlarmLimitsFromPV().setValue(false); + original.propLevelHigh().setValue(80.0); + original.propMinimum().setValue(100.0); + original.propMaximum().setValue(0.0); + + final String xml = write(original, false); + assertThat(xml, containsString("")); + assertThat(xml, containsString("")); + assertThat(xml, containsString("")); + + final ProgressBarWidget bar = (ProgressBarWidget) read(xml); + assertThat(bar.propScaleVisible().getValue(), equalTo(true)); + assertThat(bar.propShowScaleLabels().getValue(), equalTo(false)); + assertThat(bar.propOppositeScaleVisible().getValue(), equalTo(true)); + assertThat(bar.propPerpendicularTickLabels().getValue(), equalTo(true)); + assertThat(bar.propBorderWidth().getValue(), equalTo(2)); + assertThat(bar.propInnerPadding().getValue(), equalTo(7)); + assertThat(bar.propFormat().getValue(), equalTo(ScaleFormat.EXPONENTIAL)); + assertThat(bar.propPrecision().getValue(), equalTo(1)); + assertThat(bar.propShowAlarmLimits().getValue(), equalTo(true)); + assertThat(bar.propAlarmLimitsFromPV().getValue(), equalTo(false)); + assertThat(bar.propLevelHigh().getValue(), equalTo(80.0)); + assertThat(bar.propMinimum().getValue(), equalTo(100.0)); + assertThat(bar.propMaximum().getValue(), equalTo(0.0)); + } + + /** Only changed properties are written, so a file that uses the + * pre-existing properties looks the same as before */ + @Test + public void testLegacyPropertiesWriteNoNewElements() throws Exception + { + final ProgressBarWidget bar = new ProgressBarWidget(); + bar.propFillColor().setValue(new WidgetColor(1, 2, 3)); + bar.propMinimum().setValue(5.0); + bar.propMaximum().setValue(50.0); + bar.propLogScale().setValue(true); + bar.propHorizontal().setValue(false); + final String xml = write(bar, true); + for (String name : List.of("fill_color", "minimum", "maximum", "log_scale", "horizontal")) + assertThat(xml, containsString("<" + name + ">")); + for (String name : ProgressBarWidget.SCALE_MODE_PROPS) + assertThat(xml, not(containsString("<" + name + ">"))); + } + + /** A BOY progress bar is imported with its scale and levels, and the + * generic BOY 'border_width' element does not become a bar border */ + @Test + public void testBoyFileLoads() throws Exception + { + final String xml = + "\n" + + "\n" + + " \n" + + " Bar\n" + + " loc://x\n" + + " 10\n" + + " 10\n" + + " 200\n" + + " 60\n" + + " 0\n" + + " 1\n" + + " false\n" + + " true\n" + + " false\n" + + " 80.0\n" + + " 90.0\n" + + " 20.0\n" + + " 10.0\n" + + " false\n" + + " 0.0\n" + + " 50.0\n" + + " \n" + + ""; + final ProgressBarWidget bar = (ProgressBarWidget) read(xml); + + assertThat(bar.propBorderWidth().getValue(), equalTo(0)); + assertThat(bar.propScaleVisible().getValue(), equalTo(false)); + assertThat(bar.propLevelHigh().getValue(), equalTo(80.0)); + assertThat(bar.propLevelHiHi().getValue(), equalTo(90.0)); + assertThat(bar.propLevelLow().getValue(), equalTo(20.0)); + assertThat(bar.propLevelLoLo().getValue(), equalTo(10.0)); + assertThat(bar.propLimitsFromPV().getValue(), equalTo(false)); + assertThat(bar.propMaximum().getValue(), equalTo(50.0)); + // BOY reserved 25 px above the bar for the markers + assertThat(bar.propY().getValue(), equalTo(35)); + assertThat(bar.propHeight().getValue(), equalTo(35)); + } + + private static String write(final Widget widget, final boolean skipDefaults) throws Exception + { + final DisplayModel model = new DisplayModel(); + model.runtimeChildren().addChild(widget); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final boolean saved = ModelWriter.skip_defaults; + ModelWriter.skip_defaults = skipDefaults; + try (ModelWriter writer = new ModelWriter(out)) + { + writer.writeModel(model); + } + finally + { + ModelWriter.skip_defaults = saved; + } + return out.toString(); + } + + private static Widget read(final String xml) throws Exception + { + final ModelReader reader = new ModelReader(new ByteArrayInputStream(xml.getBytes())); + return reader.readModel().getChildren().get(0); + } +} diff --git a/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/TankWidgetUnitTest.java b/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/TankWidgetUnitTest.java index 57fe2b840b..82df2a5bd1 100644 --- a/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/TankWidgetUnitTest.java +++ b/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/TankWidgetUnitTest.java @@ -86,6 +86,18 @@ public void testTankWidgetDefaults() assertThat(tank.propBorderWidth().getValue(), equalTo(0)); } + /** The Tank defines the shared scale look properties in its own order; + * the list for representations must contain all of them */ + @Test + public void testScaleLookProperties() + { + final TankWidget tank = new TankWidget(); + final List> look = tank.getScaleLookProperties(); + assertThat(look.size(), equalTo(10)); + assertTrue(look.contains(tank.propBorderWidth())); + assertTrue(look.contains(tank.propForeground())); + } + /** Verify that alarm properties appear together and in the expected * order when listed in the property panel. * From f5a709c9fc311c584568de241327c6d6406232d4 Mon Sep 17 00:00:00 2001 From: Emilio Heredia Date: Fri, 18 Sep 2026 13:46:02 -0600 Subject: [PATCH 3/7] feat(display): RTTank based Progress Bar renderer, opt-in via preference Add RTProgressBarRepresentation, which draws the progress bar with the RTTank engine shared with the Tank widget. This gives the bar a numeric scale with format and precision, an optional opposite scale, minor ticks, tick-only mode for stacked bars, and alarm limit lines. RTTank gains an inner padding and a bar track, framed and shaded like the JavaFX progress bar. RTScaledWidgetRepresentation gains registerScaleLookListeners() and applyScaleLook() for the scale look properties that ScaledPVWidget defines; TankRepresentation and the new representation use them and only handle their own extras. The renderer is selected with the new preference org.csstudio.display.builder.representation/progressbar_scale_mode which defaults to false. With the default, the stock JavaFX ProgressBarRepresentation is used and nothing changes for existing displays. The property panel hides the renderer-only properties while the stock renderer is in use. --- .../properties/PropertyPanelSection.java | 21 ++ .../widgets/BaseWidgetRepresentations.java | 5 +- .../widgets/RTProgressBarRepresentation.java | 69 ++++++ .../widgets/RTScaledWidgetRepresentation.java | 54 +++++ .../javafx/widgets/TankRepresentation.java | 45 +--- .../builder/representation/Preferences.java | 7 + ...play_representation_preferences.properties | 7 + .../org/csstudio/javafx/rtplot/RTTank.java | 203 ++++++++++++++++-- 8 files changed, 352 insertions(+), 59 deletions(-) create mode 100644 app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTProgressBarRepresentation.java diff --git a/app/display/editor/src/main/java/org/csstudio/display/builder/editor/properties/PropertyPanelSection.java b/app/display/editor/src/main/java/org/csstudio/display/builder/editor/properties/PropertyPanelSection.java index cd971b0583..fe4502af1d 100644 --- a/app/display/editor/src/main/java/org/csstudio/display/builder/editor/properties/PropertyPanelSection.java +++ b/app/display/editor/src/main/java/org/csstudio/display/builder/editor/properties/PropertyPanelSection.java @@ -59,6 +59,8 @@ import org.csstudio.display.builder.model.properties.RulesWidgetProperty; import org.csstudio.display.builder.model.properties.ScriptsWidgetProperty; import org.csstudio.display.builder.model.properties.WidgetClassProperty; +import org.csstudio.display.builder.model.widgets.ProgressBarWidget; +import org.csstudio.display.builder.representation.Preferences; import org.csstudio.display.builder.representation.javafx.FilenameSupport; import org.phoebus.ui.color.NamedWidgetColor; import org.phoebus.ui.color.WidgetColor; @@ -148,6 +150,22 @@ public boolean hasFocus() { return has_focus; } + /** Some widgets can be drawn by the stock JavaFX renderer or by an + * RTTank based one that adds a scale, selected via preference. + * Properties that only the RTTank renderer honours are hidden while + * the stock renderer is in use, so the panel only lists what has + * an effect. + * + * @param property Property about to be listed + * @return {@code true} if the property has no effect with the current renderer + */ + private static boolean unusedByCurrentRenderer(final WidgetProperty property) { + if (property.getWidget() instanceof ProgressBarWidget) + return !Preferences.progressbar_scale_mode + && ProgressBarWidget.SCALE_MODE_PROPS.contains(property.getName()); + return false; + } + void fill(final UndoableActionManager undo, final Collection> properties, final List other) { @@ -163,6 +181,9 @@ void fill(final UndoableActionManager undo, if (property instanceof WidgetClassProperty && class_mode) continue; + if (unusedByCurrentRenderer(property)) + continue; + // Start of new category that needs to be shown? if (property.getCategory() != category) { category = property.getCategory(); diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/BaseWidgetRepresentations.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/BaseWidgetRepresentations.java index b6db502bd7..8c9927f195 100644 --- a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/BaseWidgetRepresentations.java +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/BaseWidgetRepresentations.java @@ -55,6 +55,7 @@ import org.csstudio.display.builder.model.widgets.plots.ImageWidget; import org.csstudio.display.builder.model.widgets.plots.StripchartWidget; import org.csstudio.display.builder.model.widgets.plots.XYPlotWidget; +import org.csstudio.display.builder.representation.Preferences; import org.csstudio.display.builder.representation.WidgetRepresentation; import org.csstudio.display.builder.representation.WidgetRepresentationFactory; import org.csstudio.display.builder.representation.javafx.widgets.plots.DataBrowserRepresentation; @@ -105,7 +106,9 @@ public Widget createWidget() entry(PictureWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new PictureRepresentation()), entry(PolygonWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new PolygonRepresentation()), entry(PolylineWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new PolylineRepresentation()), - entry(ProgressBarWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new ProgressBarRepresentation()), + entry(ProgressBarWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) (Preferences.progressbar_scale_mode + ? new RTProgressBarRepresentation() + : new ProgressBarRepresentation())), entry(RadioWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new RadioRepresentation()), entry(RectangleWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new RectangleRepresentation()), entry(ScaledSliderWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new ScaledSliderRepresentation()), diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTProgressBarRepresentation.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTProgressBarRepresentation.java new file mode 100644 index 0000000000..cb07e83cb7 --- /dev/null +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTProgressBarRepresentation.java @@ -0,0 +1,69 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.csstudio.display.builder.representation.javafx.widgets; + +import org.csstudio.display.builder.model.widgets.ProgressBarWidget; +import org.csstudio.display.builder.representation.javafx.JFXUtil; + +import javafx.scene.paint.Color; + +/** Progress Bar representation based on {@link org.csstudio.javafx.rtplot.RTTank} + * + *

    Adds a numeric scale with format/precision, an optional second scale + * and alarm limit lines to the progress bar. Used instead of the stock + * {@link ProgressBarRepresentation} when the {@code progressbar_scale_mode} + * preference is set. + * + *

    Value, range, alarm limit and orientation handling are shared with + * the Tank in {@link RTScaledWidgetRepresentation}. This class only maps + * the progress bar's appearance properties onto the tank. + */ +public class RTProgressBarRepresentation extends RTScaledWidgetRepresentation +{ + @Override + protected void configureTank() + { + // Shade the empty region like the track of the stock ProgressBar + tank.setBarTrack(true); + } + + @Override + protected boolean isHorizontal() + { + return model_widget.propHorizontal().getValue(); + } + + @Override + protected void registerLookListeners() + { + registerScaleLookListeners(); + model_widget.propBackgroundColor().addUntypedPropertyListener(lookListener); + model_widget.propInnerPadding().addUntypedPropertyListener(lookListener); + model_widget.propHorizontal().addPropertyListener(orientationChangedListener); + } + + @Override + protected void unregisterLookListeners() + { + unregisterScaleLookListeners(); + model_widget.propBackgroundColor().removePropertyListener(lookListener); + model_widget.propInnerPadding().removePropertyListener(lookListener); + model_widget.propHorizontal().removePropertyListener(orientationChangedListener); + } + + @Override + protected void applyLookToTank() + { + applyScaleLook(); + // As in the JavaFX control, the background color is the color of the + // track, and nothing is painted outside the track and the scale + tank.setBackground(Color.TRANSPARENT); + tank.setEmptyColor(JFXUtil.convert(model_widget.propBackgroundColor().getValue())); + tank.setInnerPadding(model_widget.propInnerPadding().getValue()); + } +} diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTScaledWidgetRepresentation.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTScaledWidgetRepresentation.java index 4918901a5c..2f0c0e62d4 100644 --- a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTScaledWidgetRepresentation.java +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTScaledWidgetRepresentation.java @@ -45,8 +45,12 @@ * (colors, scale visibility, font, ...) *

  • {@link #applyLookToTank()}: push the current appearance properties * to the tank after size and orientation have been set
  • + *
  • {@link #configureTank()}: optional one-time tank setup
  • * * + *

    {@link #registerScaleLookListeners()} and {@link #applyScaleLook()} + * handle the scale look properties that all {@link ScaledPVWidget}s share. + * * @param concrete {@link ScaledPVWidget} subtype */ public abstract class RTScaledWidgetRepresentation @@ -83,9 +87,15 @@ public Pane createJFXNode() throws Exception { tank = new RTTank(); tank.setUpdateThrottle(Preferences.image_update_delay, TimeUnit.MILLISECONDS); + configureTank(); return new Pane(tank); } + /** Called once after the tank is created, for one-time settings like a rendering style */ + protected void configureTank() + { + } + /** Register listeners on the {@link ScaledPVWidget} value and limit * properties, then call {@link #registerLookListeners()} for the * subclass to add its widget-specific appearance listeners. @@ -121,6 +131,30 @@ protected void registerListeners() limitsChanged(null, null, null); } + /** Listen to the widget size, the label format and the scale look + * properties shared by all scaled widgets. + * Subclasses call this from {@link #registerLookListeners()}. */ + protected void registerScaleLookListeners() + { + model_widget.propWidth().addUntypedPropertyListener(lookListener); + model_widget.propHeight().addUntypedPropertyListener(lookListener); + model_widget.propFormat().addUntypedPropertyListener(lookListener); + model_widget.propPrecision().addUntypedPropertyListener(lookListener); + for (WidgetProperty property : model_widget.getScaleLookProperties()) + property.addUntypedPropertyListener(lookListener); + } + + /** Undo {@link #registerScaleLookListeners()} */ + protected void unregisterScaleLookListeners() + { + model_widget.propWidth().removePropertyListener(lookListener); + model_widget.propHeight().removePropertyListener(lookListener); + model_widget.propFormat().removePropertyListener(lookListener); + model_widget.propPrecision().removePropertyListener(lookListener); + for (WidgetProperty property : model_widget.getScaleLookProperties()) + property.removePropertyListener(lookListener); + } + /** Register listeners on widget-specific appearance properties. * The implementation should add listeners using {@link #lookListener} * (or a dedicated listener) and call nothing on the tank directly, @@ -261,6 +295,26 @@ protected void orientationChanged(final WidgetProperty prop, toolkit.scheduleUpdate(this); } + /** Push the scale look properties shared by all scaled widgets to the + * tank: font, colors, log scale, label format, scale visibility and + * tick options, border width. + * Subclasses call this from {@link #applyLookToTank()}. */ + protected void applyScaleLook() + { + tank.setFont(JFXUtil.convert(model_widget.propFont().getValue())); + tank.setForeground(JFXUtil.convert(model_widget.propForeground().getValue())); + tank.setFillColor(JFXUtil.convert(model_widget.propFillColor().getValue())); + tank.setLogScale(model_widget.propLogScale().getValue()); + tank.setLabelFormat(model_widget.propFormat().getValue(), + model_widget.propPrecision().getValue()); + tank.setScaleVisible(model_widget.propScaleVisible().getValue()); + tank.setShowMinorTicks(model_widget.propShowMinorTicks().getValue()); + tank.setScaleLabelsVisible(model_widget.propShowScaleLabels().getValue()); + tank.setRightScaleVisible(model_widget.propOppositeScaleVisible().getValue()); + tank.setPerpendicularTickLabels(model_widget.propPerpendicularTickLabels().getValue()); + tank.setBorderWidth(model_widget.propBorderWidth().getValue()); + } + /** Push the current widget-specific appearance properties to the tank. * Called from {@link #updateChanges()} after size and orientation * have been applied. */ diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/TankRepresentation.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/TankRepresentation.java index e3b7df6f68..3ff4401e9b 100644 --- a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/TankRepresentation.java +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/TankRepresentation.java @@ -15,7 +15,7 @@ *

    All shared RTTank wiring (value updates, alarm limits, * orientation handling) lives in {@link RTScaledWidgetRepresentation}. * This class contributes only the Tank-specific appearance properties: - * background, foreground, fill and empty colors. + * background and empty colors. * * @author Kay Kasemir * @author Heredie Delvalle — CLS, alarm limits, dual scale, @@ -32,63 +32,26 @@ protected boolean isHorizontal() @Override protected void registerLookListeners() { - model_widget.propWidth().addUntypedPropertyListener(lookListener); - model_widget.propHeight().addUntypedPropertyListener(lookListener); - model_widget.propFont().addUntypedPropertyListener(lookListener); - model_widget.propForeground().addUntypedPropertyListener(lookListener); + registerScaleLookListeners(); model_widget.propBackground().addUntypedPropertyListener(lookListener); - model_widget.propFillColor().addUntypedPropertyListener(lookListener); model_widget.propEmptyColor().addUntypedPropertyListener(lookListener); - model_widget.propScaleVisible().addUntypedPropertyListener(lookListener); - model_widget.propShowMinorTicks().addUntypedPropertyListener(lookListener); - model_widget.propShowScaleLabels().addUntypedPropertyListener(lookListener); - model_widget.propPerpendicularTickLabels().addUntypedPropertyListener(lookListener); - model_widget.propFormat().addUntypedPropertyListener(lookListener); - model_widget.propPrecision().addUntypedPropertyListener(lookListener); - model_widget.propOppositeScaleVisible().addUntypedPropertyListener(lookListener); - model_widget.propBorderWidth().addUntypedPropertyListener(lookListener); - model_widget.propLogScale().addUntypedPropertyListener(lookListener); model_widget.propHorizontal().addPropertyListener(orientationChangedListener); } @Override protected void unregisterLookListeners() { - model_widget.propWidth().removePropertyListener(lookListener); - model_widget.propHeight().removePropertyListener(lookListener); - model_widget.propFont().removePropertyListener(lookListener); - model_widget.propForeground().removePropertyListener(lookListener); + unregisterScaleLookListeners(); model_widget.propBackground().removePropertyListener(lookListener); - model_widget.propFillColor().removePropertyListener(lookListener); model_widget.propEmptyColor().removePropertyListener(lookListener); - model_widget.propScaleVisible().removePropertyListener(lookListener); - model_widget.propShowMinorTicks().removePropertyListener(lookListener); - model_widget.propShowScaleLabels().removePropertyListener(lookListener); - model_widget.propPerpendicularTickLabels().removePropertyListener(lookListener); - model_widget.propFormat().removePropertyListener(lookListener); - model_widget.propPrecision().removePropertyListener(lookListener); - model_widget.propOppositeScaleVisible().removePropertyListener(lookListener); - model_widget.propBorderWidth().removePropertyListener(lookListener); - model_widget.propLogScale().removePropertyListener(lookListener); model_widget.propHorizontal().removePropertyListener(orientationChangedListener); } @Override protected void applyLookToTank() { - tank.setFont(JFXUtil.convert(model_widget.propFont().getValue())); + applyScaleLook(); tank.setBackground(JFXUtil.convert(model_widget.propBackground().getValue())); - tank.setForeground(JFXUtil.convert(model_widget.propForeground().getValue())); - tank.setFillColor(JFXUtil.convert(model_widget.propFillColor().getValue())); tank.setEmptyColor(JFXUtil.convert(model_widget.propEmptyColor().getValue())); - tank.setScaleVisible(model_widget.propScaleVisible().getValue()); - tank.setShowMinorTicks(model_widget.propShowMinorTicks().getValue()); - tank.setScaleLabelsVisible(model_widget.propShowScaleLabels().getValue()); - tank.setPerpendicularTickLabels(model_widget.propPerpendicularTickLabels().getValue()); - tank.setLogScale(model_widget.propLogScale().getValue()); - tank.setLabelFormat(model_widget.propFormat().getValue(), - model_widget.propPrecision().getValue()); - tank.setRightScaleVisible(model_widget.propOppositeScaleVisible().getValue()); - tank.setBorderWidth(model_widget.propBorderWidth().getValue()); } } diff --git a/app/display/representation/src/main/java/org/csstudio/display/builder/representation/Preferences.java b/app/display/representation/src/main/java/org/csstudio/display/builder/representation/Preferences.java index d25853b120..dad8d6b34d 100644 --- a/app/display/representation/src/main/java/org/csstudio/display/builder/representation/Preferences.java +++ b/app/display/representation/src/main/java/org/csstudio/display/builder/representation/Preferences.java @@ -22,6 +22,13 @@ public class Preferences update_accumulation_time, update_delay, plot_update_delay, image_update_delay, tooltip_length, embedded_timeout; + /** When {@code true}, the Progress Bar widget uses {@link org.csstudio.javafx.rtplot.RTTank} + * as its rendering engine, which adds a numeric scale, tick format/precision, + * an optional second scale, and alarm-limit lines. + * When {@code false} (default), the stock JFX {@code ProgressBar} look is preserved. + * Requires restart to take effect. */ + @Preference public static boolean progressbar_scale_mode; + static { AnnotatedPreferences.initialize(Preferences.class, "/display_representation_preferences.properties"); diff --git a/app/display/representation/src/main/resources/display_representation_preferences.properties b/app/display/representation/src/main/resources/display_representation_preferences.properties index 32724cbba7..e3f27e73ba 100644 --- a/app/display/representation/src/main/resources/display_representation_preferences.properties +++ b/app/display/representation/src/main/resources/display_representation_preferences.properties @@ -44,3 +44,10 @@ tooltip_length=200 # Timeout for load / unload of Embedded Widget content, in milliseconds. embedded_timeout=5000 + +# When true, the Progress Bar widget uses the RTTank rendering engine, +# which adds a numeric scale, tick marks, format/precision control, +# an optional second scale, and alarm-limit lines. +# When false (default), the standard JFX ProgressBar look is preserved. +# Requires restart to take effect. +progressbar_scale_mode = false diff --git a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java index 7ed55b8b34..8770129af2 100644 --- a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java +++ b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java @@ -11,8 +11,13 @@ import java.awt.Color; import java.awt.GradientPaint; import java.awt.Graphics2D; +import java.awt.LinearGradientPaint; +import java.awt.MultipleGradientPaint; +import java.awt.Paint; import java.awt.Rectangle; import java.awt.RenderingHints; +import java.awt.geom.Area; +import java.awt.geom.RoundRectangle2D; import java.awt.image.BufferedImage; import java.text.NumberFormat; import java.util.Objects; @@ -94,6 +99,36 @@ public class RTTank extends Canvas /** Border width in pixels around the tank body; 0 = no border (default) */ private volatile int border_width = 0; + /** Extra inset from the canvas edge to the plot body on all four sides. + * 0 for the tank look. With a bar track, the track extends into this + * space, so it becomes the gap between the track and the fill. */ + private volatile int innerPadding = 0; + + /** Paint the empty part like the track of a progress bar, framed and + * shaded across the bar like the JavaFX control, instead of the tank's + * left-to-center gradient. */ + private volatile boolean barTrack = false; + + /** Stop positions of the track and fill shades, see {@link #shadesOf} */ + private static final float[] TRACK_FRACTIONS = { 0.0f, 1f/3, 2f/3, 1.0f }; + + /** Stop positions of the frame shades, see {@link #frameShadesOf} */ + private static final float[] FRAME_FRACTIONS = { 0.0f, 1.0f }; + + /** Corner arcs of the bar track frame and of the track and fill inside it, + * twice the radii of the JavaFX style sheet */ + private static final int FRAME_ARC = 6; + private static final int TRACK_ARC = 4; + + /** Shades of {@link #empty} for the bar track */ + private volatile Color[] trackShades = shadesOf(Color.LIGHT_GRAY.brighter().brighter()); + + /** Shades of the frame around the bar track */ + private volatile Color[] frameShades = frameShadesOf(255); + + /** Shades of {@link #fill} for the filled part of a bar */ + private volatile Color[] fillShades = shadesOf(Color.BLUE); + /** Current value, i.e. fill level */ private volatile double value = 5.0; @@ -219,6 +254,22 @@ public void setBorderWidth(final int width) requestUpdate(); } + /** @param pixels Extra inset from all four canvas edges to the plot body, 0..20 */ + public void setInnerPadding(final int pixels) + { + innerPadding = Math.clamp(pixels, 0, 20); + need_layout.set(true); + requestUpdate(); + } + + /** @param bar Paint the empty part like a progress bar track ({@code true}) + * or like a tank ({@code false}, default) */ + public void setBarTrack(final boolean bar) + { + barTrack = bar; + requestUpdate(); + } + /** @param color Background color */ public void setBackground(final javafx.scene.paint.Color color) { @@ -243,6 +294,94 @@ public void setEmptyColor(final javafx.scene.paint.Color color) Math.max(0, empty.getBlue() - 32), empty.getAlpha() ); + trackShades = shadesOf(empty); + frameShades = frameShadesOf(empty.getAlpha()); + } + + /** Shades of a bar color, following the JavaFX progress bar style sheet, + * which shades the track and the bar across the bar through -7%, 0%, + * -3% and -9% of the color's brightness. + * @param color Track or fill color + * @return The four shades, across the bar + */ + private static Color[] shadesOf(final Color color) + { + return new Color[] { darker(color, 7), color, darker(color, 3), darker(color, 9) }; + } + + /** Shades of the frame around the bar track, as in the stock Phoebus + * progress bar: the JavaFX style sheet shades the frame from -10% of + * the text box border color to that color, and Phoebus sets the border + * color to a light gray with the alpha of the track color. + * @param alpha Alpha of the track color + * @return The two shades, across the bar + */ + private static Color[] frameShadesOf(final int alpha) + { + final Color border = new Color(236, 236, 236, alpha); + return new Color[] { darker(border, 10), border }; + } + + /** @param color Color to darken + * @param percent How much darker, in percent of the brightness + * @return Darkened color, alpha unchanged + */ + private static Color darker(final Color color, final int percent) + { + final double scale = 1.0 - percent / 100.0; + return new Color((int) (color.getRed() * scale), + (int) (color.getGreen() * scale), + (int) (color.getBlue() * scale), + color.getAlpha()); + } + + /** Paint the track of a bar: a one pixel frame around the shaded track. + * The frame is painted as a ring, not under the track, so that a + * semi-transparent track color is not applied twice. + * @param gc Graphics + * @param track Outer bounds of the track, including the frame + */ + private void paintBarTrack(final Graphics2D gc, final Rectangle track) + { + final Rectangle inside = new Rectangle(track); + inside.grow(-1, -1); + final Area frame = new Area(new RoundRectangle2D.Double(track.x, track.y, track.width, track.height, + FRAME_ARC, FRAME_ARC)); + frame.subtract(new Area(new RoundRectangle2D.Double(inside.x, inside.y, inside.width, inside.height, + TRACK_ARC, TRACK_ARC))); + gc.setPaint(barPaint(track, FRAME_FRACTIONS, frameShades)); + gc.fill(frame); + gc.setPaint(barPaint(inside, TRACK_FRACTIONS, trackShades)); + gc.fillRoundRect(inside.x, inside.y, inside.width, inside.height, TRACK_ARC, TRACK_ARC); + } + + /** @param bounds Area to fill + * @return Paint for the filled region: a bar fill or the tank gradient + */ + private Paint fillPaint(final Rectangle bounds) + { + if (barTrack) + return barPaint(bounds, TRACK_FRACTIONS, fillShades); + final int center = bounds.x + bounds.width/2; + return new GradientPaint(bounds.x, 0, fill, center, 0, fill_highlight, true); + } + + /** @param bounds Area to fill + * @param fractions Stop positions of the shades + * @param shades Shades across the bar + * @return Gradient across the bar, in the style of the JavaFX progress bar. + * The tank renders vertically, so the bar runs along Y and the + * shading across it is along X, for both widget orientations. + * An area too narrow for a gradient gets the first shade. + */ + private static Paint barPaint(final Rectangle bounds, final float[] fractions, final Color[] shades) + { + if (bounds.width < 2) + return shades[0]; + final int right = bounds.x + bounds.width; + return new LinearGradientPaint(bounds.x, bounds.y, right, bounds.y, + fractions, shades, + MultipleGradientPaint.CycleMethod.NO_CYCLE); } /** @param color Color for filled region */ @@ -250,6 +389,7 @@ public void setFillColor(final javafx.scene.paint.Color color) { fill = GraphicsUtils.convert(Objects.requireNonNull(color)); final int saturationContribution = (int) ( 48.f * Color.RGBtoHSB(fill.getRed(), fill.getGreen(), fill.getBlue(), null)[1] ); + fillShades = shadesOf(fill); fill_highlight = new Color( Math.min(255, fill.getRed() + 32 + saturationContribution), Math.min(255, fill.getGreen() + 32 + saturationContribution), @@ -560,11 +700,16 @@ private void computeLayout(final Graphics2D gc, final Rectangle bounds) // Inset = ceil(border_width/2) keeps the outer stroke edge inside the canvas. // On sides with a scale the label area provides ample margin so inset=0. // When there is no border, inset=1 is the original clip guard. + // innerPadding is added on all four sides regardless of scale presence. + // A bar track grows back into it, so next to a scale it keeps a gap + // for the border and one pixel, clear of the axis line. final int half_bw_ceil = (border_width + 1) / 2; - final int inset_left = (left_width == 0) ? Math.max(1, half_bw_ceil) : 0; - final int inset_right = (right_width == 0) ? Math.max(1, half_bw_ceil) : 0; - final int inset_top = (ends[1] == 0) ? Math.max(1, half_bw_ceil) : 0; - final int inset_bottom = (ends[0] == 0) ? Math.max(1, half_bw_ceil) : 0; + final int padding = innerPadding; + final int scaleGap = barTrack ? half_bw_ceil + 1 : 0; + final int inset_left = (left_width == 0) ? Math.max(1, half_bw_ceil) + padding : padding + scaleGap; + final int inset_right = (right_width == 0) ? Math.max(1, half_bw_ceil) + padding : padding + scaleGap; + final int inset_top = (ends[1] == 0) ? Math.max(1, half_bw_ceil) + padding : padding; + final int inset_bottom = (ends[0] == 0) ? Math.max(1, half_bw_ceil) + padding : padding; final int top = bounds.y + ends[1] + inset_top; final int height = bounds.height - ends[0] - ends[1] - inset_top - inset_bottom; @@ -619,22 +764,47 @@ protected Image updateImageBuffer() final double current = value; final int level = computeFillLevel(plot_bounds.height, min, max, current, scale.isLogarithmic()); - final int arc = Math.min(plot_bounds.width, plot_bounds.height) / 10; - gc.setPaint(new GradientPaint(plot_bounds.x, 0, empty, plot_bounds.x+plot_bounds.width/2, 0, empty_shadow, true)); + // A widget too small for its padding and scale has no room for the + // fill, so draw no body rather than one that looks empty + if (plot_bounds.width <= 0 || plot_bounds.height <= 0) + { + gc.dispose(); + return SwingFXUtils.toFXImage(image, null); + } - gc.fillRoundRect(plot_bounds.x, plot_bounds.y, plot_bounds.width, plot_bounds.height, arc, arc); + final int arc = Math.min(plot_bounds.width, plot_bounds.height) / 10; + // The body is the tank, or the track of a bar. The track extends into + // the inner padding so that the fill sits inside it, like in the + // JavaFX progress bar. + final Rectangle body = new Rectangle(plot_bounds); + final int bodyArc; + final int fillArc; + if (barTrack) + { + body.grow(innerPadding, innerPadding); + bodyArc = FRAME_ARC; + fillArc = TRACK_ARC; + paintBarTrack(gc, body); + } + else + { + bodyArc = fillArc = arc; + final int center = plot_bounds.x + plot_bounds.width/2; + gc.setPaint(new GradientPaint(plot_bounds.x, 0, empty, center, 0, empty_shadow, true)); + gc.fillRoundRect(plot_bounds.x, plot_bounds.y, plot_bounds.width, plot_bounds.height, arc, arc); + } - gc.setPaint(new GradientPaint(plot_bounds.x, 0, fill, plot_bounds.x+plot_bounds.width/2, 0, fill_highlight, true)); + gc.setPaint(fillPaint(plot_bounds)); if (normal) - gc.fillRoundRect(plot_bounds.x, plot_bounds.y+plot_bounds.height-level, plot_bounds.width, level, arc, arc); + gc.fillRoundRect(plot_bounds.x, plot_bounds.y+plot_bounds.height-level, plot_bounds.width, level, fillArc, fillArc); else - gc.fillRoundRect(plot_bounds.x, plot_bounds.y, plot_bounds.width, level, arc, arc); + gc.fillRoundRect(plot_bounds.x, plot_bounds.y, plot_bounds.width, level, fillArc, fillArc); - // Optional border: stroked CENTRED on plot_bounds — no integer half-pixel + // Optional border: stroked CENTRED on the body — no integer half-pixel // shifting. The inner half of the stroke covers the fill edge (no gap); - // the outer half extends beyond plot_bounds into the inset margin. - // Ticks land at plot_bounds edges = centre of the border stroke, matching - // the CS-Studio BOY convention. + // the outer half extends beyond the body into the inset margin. + // For a tank, ticks land at the body edges = centre of the border stroke, + // matching the CS-Studio BOY convention. if (border_width > 0) { // Java2D: fillRoundRect covers x..x+w-1, drawRoundRect strokes x..x+w. @@ -642,9 +812,8 @@ protected Image updateImageBuffer() // making all four edges symmetric. gc.setColor(foreground); gc.setStroke(new BasicStroke(border_width)); - gc.drawRoundRect(plot_bounds.x, plot_bounds.y, - plot_bounds.width - 1, plot_bounds.height - 1, - arc, arc); + gc.drawRoundRect(body.x, body.y, body.width - 1, body.height - 1, + bodyArc, bodyArc); gc.setStroke(new BasicStroke(1f)); } From 3f97cc5e2b615fad884215019d1c62de3a00651b Mon Sep 17 00:00:00 2001 From: Emilio Heredia Date: Fri, 18 Sep 2026 13:46:02 -0600 Subject: [PATCH 4/7] feat(convert-edm): map EDM bar scale, range, precision and border The EDM 'activeBar' carries a scale flag, a fixed range, a precision and a border flag that the progress bar can now represent. --- .../edm/widgets/Convert_activeBarClass.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/display/convert-edm/src/main/java/org/csstudio/display/converter/edm/widgets/Convert_activeBarClass.java b/app/display/convert-edm/src/main/java/org/csstudio/display/converter/edm/widgets/Convert_activeBarClass.java index a7acd31982..79c1814872 100644 --- a/app/display/convert-edm/src/main/java/org/csstudio/display/converter/edm/widgets/Convert_activeBarClass.java +++ b/app/display/convert-edm/src/main/java/org/csstudio/display/converter/edm/widgets/Convert_activeBarClass.java @@ -28,7 +28,22 @@ public Convert_activeBarClass(final EdmConverter converter, final Widget parent, convertColor(r.getBgColor(), widget.propBackgroundColor()); widget.propHorizontal().setValue(!"vertical".equals(r.getOrientation())); widget.propPVName().setValue(convertPVName(r.getIndicatorPv())); + + widget.propScaleVisible().setValue(r.isShowScale()); widget.propLimitsFromPV().setValue(r.isLimitsFromDb()); + if (!r.isLimitsFromDb() && r.getMax() > r.getMin()) + { + widget.propMinimum().setValue(r.getMin()); + widget.propMaximum().setValue(r.getMax()); + } + + // EDM precision 0 usually means "not set" + if (r.getPrecision() > 0) + widget.propPrecision().setValue(r.getPrecision()); + + // EDM only knows border on/off + if (r.isBorder()) + widget.propBorderWidth().setValue(1); } @Override From f2d551df0999f07f36293a3843844ffd735a600e Mon Sep 17 00:00:00 2001 From: Emilio Heredia Date: Fri, 18 Sep 2026 13:50:13 -0600 Subject: [PATCH 5/7] feat(display): make ThermometerWidget a ScaledPVWidget Move the thermometer onto the ScaledPVWidget base class, like the Tank and the Progress Bar, so it carries the same range, scale and alarm limit properties through defineScaleLookProperties(), plus an empty color, like the Tank, and a bulb size. The scale is off by default, so a thermometer keeps its stock proportions until a scale is asked for. The stock renderer ignores the additions. Existing .bob and BOY files keep loading unchanged since the pre-existing properties keep their names and the border uses the 'tank_border_width' name. ThermometerWidgetUnitTest covers defaults, legacy .bob and BOY files, the XML round trip and the list of renderer-only properties. --- .../display/builder/model/Messages.java | 1 + .../model/widgets/ThermometerWidget.java | 92 ++++---- .../display/builder/model/messages.properties | 1 + .../builder/model/messages_fr.properties | 1 + .../widgets/ThermometerWidgetUnitTest.java | 199 ++++++++++++++++++ 5 files changed, 252 insertions(+), 42 deletions(-) create mode 100644 app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/ThermometerWidgetUnitTest.java diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java index 9a62e6a502..a758896ebd 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java @@ -185,6 +185,7 @@ public class Messages WidgetProperties_BorderAlarmSensitive, WidgetProperties_BorderColor, WidgetProperties_BorderWidth, + WidgetProperties_BulbSize, WidgetProperties_CellColors, WidgetProperties_Class, WidgetProperties_ColorHiHi, diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ThermometerWidget.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ThermometerWidget.java index 7fab8e066f..bd21e8d191 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ThermometerWidget.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ThermometerWidget.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2015-2016 Oak Ridge National Laboratory. + * Copyright (c) 2015-2026 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -7,29 +7,61 @@ *******************************************************************************/ package org.csstudio.display.builder.model.widgets; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFillColor; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propLimitsFromPV; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propMaximum; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propMinimum; +import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newIntegerPropertyDescriptor; import java.util.Arrays; import java.util.List; +import java.util.Set; +import org.csstudio.display.builder.model.Messages; import org.csstudio.display.builder.model.Widget; import org.csstudio.display.builder.model.WidgetCategory; import org.csstudio.display.builder.model.WidgetDescriptor; import org.csstudio.display.builder.model.WidgetProperty; +import org.csstudio.display.builder.model.WidgetPropertyCategory; +import org.csstudio.display.builder.model.WidgetPropertyDescriptor; import org.phoebus.ui.color.WidgetColor; -/** - * Widget of a thermometer +/** Widget that displays a thermometer + * + *

    Extends {@link ScaledPVWidget} so the thermometer offers the same + * range, scale and alarm limit properties as the {@link TankWidget}. + * The scale related properties only take effect with the RTTank based + * renderer, see {@link #SCALE_MODE_PROPS}. + * + *

    Existing {@code .bob} files load unchanged: {@code fill_color}, + * {@code limits_from_pv}, {@code minimum} and {@code maximum} keep their + * XML names. Older Phoebus versions ignore the new properties. * * @author Amanda Carpenter */ -public class ThermometerWidget extends PVWidget +@SuppressWarnings("nls") +public class ThermometerWidget extends ScaledPVWidget { + /** Properties that only have an effect with the RTTank based renderer + * ({@code thermometer_scale_mode=true}). + * The property editor hides these while the stock renderer is in use. */ + public static final Set SCALE_MODE_PROPS = Set.of( + "format", "precision", + "empty_color", "foreground_color", "font", + "log_scale", + "scale_visible", "show_minor_ticks", "show_scale_labels", + "opposite_scale_visible", "perpendicular_tick_labels", + "tank_border_width", + "bulb_size", + "alarm_limits_from_pv", "show_alarm_limits", + "level_lolo", "level_low", "level_high", "level_hihi", + "minor_alarm_color", "major_alarm_color"); + + /** 'bulb_size': how much wider than the tube the bulb is, in pixels (0..50). + * The bulb is always drawn; 0 gives the narrowest bulb, not none. + * With the default of 20 and no scale, a thermometer of the default + * width has about the proportions of the stock thermometer. */ + public static final WidgetPropertyDescriptor propBulbSize = + newIntegerPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "bulb_size", + Messages.WidgetProperties_BulbSize, 0, 50); + /** Widget descriptor */ - @SuppressWarnings("nls") public static final WidgetDescriptor WIDGET_DESCRIPTOR = new WidgetDescriptor("thermometer", WidgetCategory.MONITOR, "Thermometer", @@ -44,7 +76,8 @@ public Widget createWidget() } }; - //TODO: configurator that ignores if show_bulb property is false (vertical progress bar instead) + private volatile WidgetProperty emptyColor; + private volatile WidgetProperty bulbSize; /** Constructor */ public ThermometerWidget() @@ -52,43 +85,18 @@ public ThermometerWidget() super(WIDGET_DESCRIPTOR.getType(), 40, 160); } - private volatile WidgetProperty limits_from_pv; - private volatile WidgetProperty minimum; - private volatile WidgetProperty maximum; - private volatile WidgetProperty fill_color; - @Override protected void defineProperties(final List> properties) { super.defineProperties(properties); - properties.add(fill_color = propFillColor.createProperty(this, new WidgetColor(60, 255, 60))); - properties.add(limits_from_pv = propLimitsFromPV.createProperty(this, true)); - properties.add(minimum = propMinimum.createProperty(this, 0.0)); - properties.add(maximum = propMaximum.createProperty(this, 100.0)); - } - - /** @return 'fill_color' property */ - public WidgetProperty propFillColor() - { - return fill_color; + defineScaleLookProperties(properties, false, true); + properties.add(emptyColor = TankWidget.propEmptyColor.createProperty(this, new WidgetColor(250, 250, 250))); + properties.add(bulbSize = propBulbSize.createProperty(this, 20)); } - /** @return 'limits_from_pv' property */ - public WidgetProperty propLimitsFromPV() - { - return limits_from_pv; - } - - /** @return 'minimum' property */ - public WidgetProperty propMinimum() - { - return minimum; - } - - /** @return 'maximum' property */ - public WidgetProperty propMaximum() - { - return maximum; - } + /** @return 'empty_color' property, the color of the empty part of the tube */ + public WidgetProperty propEmptyColor() { return emptyColor; } + /** @return 'bulb_size' property */ + public WidgetProperty propBulbSize() { return bulbSize; } } diff --git a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties index 850272fa44..632fca5e04 100644 --- a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties +++ b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties @@ -171,6 +171,7 @@ WidgetProperties_Bit=Bit WidgetProperties_BorderAlarmSensitive=Alarm Border WidgetProperties_BorderColor=Border Color WidgetProperties_BorderWidth=Border Width +WidgetProperties_BulbSize=Bulb Size WidgetProperties_CellColors=Cell Colors WidgetProperties_Class=Class WidgetProperties_ColorHiHi=Color HiHi diff --git a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties index 1b8ddb720b..3425d6bb58 100644 --- a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties +++ b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties @@ -171,6 +171,7 @@ WidgetProperties_Bit=Bit WidgetProperties_BorderAlarmSensitive=Alarme de bordure WidgetProperties_BorderColor=Couleur de la bordure WidgetProperties_BorderWidth=Largeur de la bordure +WidgetProperties_BulbSize=Taille du bulbe WidgetProperties_CellColors=Couleurs des cellules WidgetProperties_Class=Classe WidgetProperties_ColorHiHi=Couleur HiHi diff --git a/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/ThermometerWidgetUnitTest.java b/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/ThermometerWidgetUnitTest.java new file mode 100644 index 0000000000..20d7a9cfea --- /dev/null +++ b/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/ThermometerWidgetUnitTest.java @@ -0,0 +1,199 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.csstudio.display.builder.model.widgets; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.List; + +import org.csstudio.display.builder.model.DisplayModel; +import org.csstudio.display.builder.model.Widget; +import org.csstudio.display.builder.model.persist.ModelReader; +import org.csstudio.display.builder.model.persist.ModelWriter; +import org.junit.jupiter.api.Test; +import org.phoebus.ui.color.WidgetColor; +import org.phoebus.ui.vtype.ScaleFormat; + +/** JUnit tests for {@link ThermometerWidget} as a {@link ScaledPVWidget} + * + *

    The thermometer must keep loading the files written before it had + * a scale, and files written now must not confuse an older Phoebus. + */ +@SuppressWarnings("nls") +public class ThermometerWidgetUnitTest +{ + /** Defaults, in particular those that the stock renderer relies on */ + @Test + public void testDefaults() + { + final ThermometerWidget thermo = new ThermometerWidget(); + + assertThat(thermo.propWidth().getValue(), equalTo(40)); + assertThat(thermo.propHeight().getValue(), equalTo(160)); + assertThat(thermo.propLimitsFromPV().getValue(), equalTo(true)); + assertThat(thermo.propMinimum().getValue(), equalTo(0.0)); + assertThat(thermo.propMaximum().getValue(), equalTo(100.0)); + assertThat(thermo.propFillColor().getValue(), equalTo(new WidgetColor(60, 255, 60))); + + // Looks like the stock thermometer until a scale is asked for; + // horizontal labels read naturally next to a vertical tube + assertThat(thermo.propScaleVisible().getValue(), equalTo(false)); + assertThat(thermo.propPerpendicularTickLabels().getValue(), equalTo(true)); + assertThat(thermo.propShowMinorTicks().getValue(), equalTo(true)); + assertThat(thermo.propShowScaleLabels().getValue(), equalTo(true)); + assertThat(thermo.propOppositeScaleVisible().getValue(), equalTo(false)); + assertThat(thermo.propLogScale().getValue(), equalTo(false)); + assertThat(thermo.propBorderWidth().getValue(), equalTo(0)); + assertThat(thermo.propEmptyColor().getValue(), equalTo(new WidgetColor(250, 250, 250))); + assertThat(thermo.propBulbSize().getValue(), equalTo(20)); + assertThat(thermo.propFormat().getValue(), equalTo(ScaleFormat.DEFAULT)); + assertThat(thermo.propShowAlarmLimits().getValue(), equalTo(false)); + } + + /** Every name that the property panel hides for the stock renderer must be a real property */ + @Test + public void testScaleModePropertiesExist() + { + final ThermometerWidget thermo = new ThermometerWidget(); + for (String name : ThermometerWidget.SCALE_MODE_PROPS) + assertTrue(thermo.checkProperty(name).isPresent(), "Unknown property " + name); + } + + /** A file written before the thermometer had a scale must load as before */ + @Test + public void testLegacyFileLoads() throws Exception + { + final String xml = + "\n" + + "\n" + + " \n" + + " Thermo\n" + + " loc://x\n" + + " \n" + + " false\n" + + " -10.0\n" + + " 40.0\n" + + " \n" + + ""; + final ThermometerWidget thermo = (ThermometerWidget) read(xml); + + assertThat(thermo.propFillColor().getValue(), equalTo(new WidgetColor(10, 20, 30))); + assertThat(thermo.propLimitsFromPV().getValue(), equalTo(false)); + assertThat(thermo.propMinimum().getValue(), equalTo(-10.0)); + assertThat(thermo.propMaximum().getValue(), equalTo(40.0)); + assertThat(thermo.propBulbSize().getValue(), equalTo(20)); + } + + /** Non-default values of the new properties survive save and load */ + @Test + public void testXmlRoundTrip() throws Exception + { + final ThermometerWidget original = new ThermometerWidget(); + original.propScaleVisible().setValue(true); + original.propOppositeScaleVisible().setValue(true); + original.propLogScale().setValue(true); + original.propBorderWidth().setValue(1); + original.propBulbSize().setValue(35); + original.propEmptyColor().setValue(new WidgetColor(1, 2, 3)); + original.propFormat().setValue(ScaleFormat.DECIMAL); + original.propPrecision().setValue(0); + original.propShowAlarmLimits().setValue(true); + original.propLevelLoLo().setValue(5.0); + + final String xml = write(original, false); + assertThat(xml, containsString("")); + assertThat(xml, containsString("")); + + final ThermometerWidget thermo = (ThermometerWidget) read(xml); + assertThat(thermo.propScaleVisible().getValue(), equalTo(true)); + assertThat(thermo.propOppositeScaleVisible().getValue(), equalTo(true)); + assertThat(thermo.propLogScale().getValue(), equalTo(true)); + assertThat(thermo.propBorderWidth().getValue(), equalTo(1)); + assertThat(thermo.propBulbSize().getValue(), equalTo(35)); + assertThat(thermo.propEmptyColor().getValue(), equalTo(new WidgetColor(1, 2, 3))); + assertThat(thermo.propFormat().getValue(), equalTo(ScaleFormat.DECIMAL)); + assertThat(thermo.propPrecision().getValue(), equalTo(0)); + assertThat(thermo.propShowAlarmLimits().getValue(), equalTo(true)); + assertThat(thermo.propLevelLoLo().getValue(), equalTo(5.0)); + } + + /** Only changed properties are written, so a file that uses the + * pre-existing properties looks the same as before */ + @Test + public void testLegacyPropertiesWriteNoNewElements() throws Exception + { + final ThermometerWidget thermo = new ThermometerWidget(); + thermo.propFillColor().setValue(new WidgetColor(1, 2, 3)); + thermo.propLimitsFromPV().setValue(false); + thermo.propMinimum().setValue(5.0); + thermo.propMaximum().setValue(50.0); + final String xml = write(thermo, true); + for (String name : List.of("fill_color", "limits_from_pv", "minimum", "maximum")) + assertThat(xml, containsString("<" + name + ">")); + for (String name : ThermometerWidget.SCALE_MODE_PROPS) + assertThat(xml, not(containsString("<" + name + ">"))); + } + + /** A BOY thermometer is imported as before; its generic BOY + * 'border_width' element does not become a glass outline */ + @Test + public void testBoyFileLoads() throws Exception + { + final String xml = + "\n" + + "\n" + + " \n" + + " Thermo\n" + + " loc://x\n" + + " 0\n" + + " 1\n" + + " \n" + + " false\n" + + " -10.0\n" + + " 40.0\n" + + " \n" + + ""; + final ThermometerWidget thermo = (ThermometerWidget) read(xml); + + assertThat(thermo.propBorderWidth().getValue(), equalTo(0)); + assertThat(thermo.propFillColor().getValue(), equalTo(new WidgetColor(10, 20, 30))); + assertThat(thermo.propLimitsFromPV().getValue(), equalTo(false)); + assertThat(thermo.propMinimum().getValue(), equalTo(-10.0)); + assertThat(thermo.propMaximum().getValue(), equalTo(40.0)); + } + + private static String write(final Widget widget, final boolean skipDefaults) throws Exception + { + final DisplayModel model = new DisplayModel(); + model.runtimeChildren().addChild(widget); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final boolean saved = ModelWriter.skip_defaults; + ModelWriter.skip_defaults = skipDefaults; + try (ModelWriter writer = new ModelWriter(out)) + { + writer.writeModel(model); + } + finally + { + ModelWriter.skip_defaults = saved; + } + return out.toString(); + } + + private static Widget read(final String xml) throws Exception + { + final ModelReader reader = new ModelReader(new ByteArrayInputStream(xml.getBytes())); + return reader.readModel().getChildren().get(0); + } +} From 05b25dbccae197236666ea7d8b70215c8691835d Mon Sep 17 00:00:00 2001 From: Emilio Heredia Date: Fri, 18 Sep 2026 13:50:13 -0600 Subject: [PATCH 6/7] feat(rtplot): thermometer look for RTTank Add a thermometer style to RTTank: a narrow tube, centered in the plot area, with a bulb at the bottom. The scales sit right next to the tube and only span the tube, so the liquid level and the alarm limit lines are placed through the scale transform and always line up with the tick marks. Tube and bulb widen with the widget up to a cap, like the stock thermometer, and the bulb size can be adjusted. A widget that is too narrow for scale and bulb keeps tube and bulb in view. The tank look is not affected. The tank body drawing moves into its own method so that updateImageBuffer() only dispatches on the style. RTTankTest checks the thermometer geometry for degenerate sizes. --- .../org/csstudio/javafx/rtplot/RTTank.java | 405 ++++++++++++++++-- .../csstudio/javafx/rtplot/RTTankTest.java | 31 ++ 2 files changed, 394 insertions(+), 42 deletions(-) diff --git a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java index 8770129af2..6dbc0d02b9 100644 --- a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java +++ b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java @@ -16,7 +16,10 @@ import java.awt.Paint; import java.awt.Rectangle; import java.awt.RenderingHints; +import java.awt.Stroke; +import java.awt.geom.Arc2D; import java.awt.geom.Area; +import java.awt.geom.Path2D; import java.awt.geom.RoundRectangle2D; import java.awt.image.BufferedImage; import java.text.NumberFormat; @@ -46,6 +49,8 @@ * *

    Renders a vertical tank with fill level, optional left and right * scales, a foreground outline, and optional alarm/warning limit lines. + * With {@link #setThermometerStyle} the tank is drawn as a thermometer, + * a narrow tube with a bulb, using the same scales and limit lines. * The dual-scale layout is modelled after CS-Studio BOY's tank widget * which supported markers on both sides of the bar. * @@ -129,6 +134,34 @@ public class RTTank extends Canvas /** Shades of {@link #fill} for the filled part of a bar */ private volatile Color[] fillShades = shadesOf(Color.BLUE); + /** Draw as a thermometer instead of a tank: a narrow tube, centered + * in the plot area, with a bulb at the bottom. Scale, value mapping + * and alarm limits are shared with the tank look. */ + private volatile boolean thermometerStyle = false; + + /** Extra bulb diameter beyond the tube width, in pixels. + * Thermometer look only; the bulb is clamped to the space left by the scales. */ + private volatile int bulbSize = 20; + + /** Thermometer geometry, computed by {@link #thermoGeometry} and + * shared by tube, bulb, liquid and scales so they stay aligned. + * All values are canvas pixels. */ + record ThermoGeom(double centerX, double tubeWidth, + double tubeTop, double tubeBottom, + double bulbCenterY, double bulbRadius) + { + double tubeLeft() { return centerX - tubeWidth / 2; } + double tubeRight() { return centerX + tubeWidth / 2; } + double bulbDiameter() { return 2 * bulbRadius; } + } + + /** Pixels reserved around the thermometer for the scales: width of the + * left and right scale, label overhang at the top and bottom */ + record ScaleSpace(int left, int right, int top, int bottom) {} + + /** Geometry from the most recent thermometer layout, {@code null} until then */ + private volatile ThermoGeom thermoGeom = null; + /** Current value, i.e. fill level */ private volatile double value = 5.0; @@ -254,7 +287,9 @@ public void setBorderWidth(final int width) requestUpdate(); } - /** @param pixels Extra inset from all four canvas edges to the plot body, 0..20 */ + /** @param pixels Extra inset from all four canvas edges to the plot body, 0..20. + * For a bar, the gap between the track and the fill. + * The thermometer look keeps a fixed margin instead. */ public void setInnerPadding(final int pixels) { innerPadding = Math.clamp(pixels, 0, 20); @@ -270,6 +305,26 @@ public void setBarTrack(final boolean bar) requestUpdate(); } + /** Select the thermometer look: a narrow tube with a bulb at the bottom + * instead of the full width tank body. Scale, value mapping and alarm + * limits behave the same in both looks. + * @param thermometer {@code true} for thermometer, {@code false} (default) for tank */ + public void setThermometerStyle(final boolean thermometer) + { + thermometerStyle = thermometer; + need_layout.set(true); + requestUpdate(); + } + + /** @param pixels Extra bulb diameter beyond the tube width (thermometer look), + * clamped at layout time so the bulb fits the plot area */ + public void setBulbSize(final int pixels) + { + bulbSize = Math.max(0, pixels); + need_layout.set(true); + requestUpdate(); + } + /** @param color Background color */ public void setBackground(final javafx.scene.paint.Color color) { @@ -673,6 +728,41 @@ private static int computeFillLevel(final int plotHeight, final double min, fina return (int) (plotHeight * (current - min) / (max - min) + 0.5); } + /** @return Is at least one alarm limit line configured? */ + private boolean hasLimitLines() + { + return !Double.isNaN(limit_lolo) || !Double.isNaN(limit_lo) || + !Double.isNaN(limit_hi) || !Double.isNaN(limit_hihi); + } + + /** @return Stroke for limit lines: solid for limits from the PV, + * dashed for manually configured ones */ + private Stroke limitLineStroke() + { + if (limits_from_pv) + return new BasicStroke(2f); + return new BasicStroke(2f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, + 10f, new float[] { 6f, 4f }, 0f); + } + + // Thermometer look. Sizes in pixels. + + /** The tube widens with the widget up to a cap, as in the stock thermometer */ + private static final int TUBE_MIN_WIDTH = 6; + private static final int TUBE_MAX_WIDTH = 20; + + /** Tube height kept above the bulb, so the bulb never swallows the tube */ + private static final int TUBE_MIN_HEIGHT = 8; + + /** The bulb is always at least this much wider than the tube */ + private static final int BULB_MIN_OVERHANG = 6; + + /** Gap between a scale and the tube wall */ + private static final int SCALE_GAP = 3; + + /** Margin between the thermometer and the edge of the widget */ + private static final int THERMO_MARGIN = 3; + /** Compute layout of plot components. * Supports independent left and right scales; the plot area sits * between them. A 1-pixel inset is added on any edge that has no @@ -680,6 +770,12 @@ private static int computeFillLevel(final int plotHeight, final double min, fina */ private void computeLayout(final Graphics2D gc, final Rectangle bounds) { + if (thermometerStyle) + { + computeThermoLayout(gc, bounds); + return; + } + int left_width = 0; int right_width = 0; int[] ends = { 0, 0 }; // [bottom gap, top gap] @@ -724,6 +820,233 @@ private void computeLayout(final Graphics2D gc, final Rectangle bounds) bounds.width - left_width - right_width - inset_left - inset_right, height); } + /** Thermometer layout + * + *

    Unlike the tank, the scales sit right next to the narrow tube and + * only span the tube, not the bulb. Scales, tube and bulb are centered + * as a whole in the available width. + * The geometry is kept in {@link #thermoGeom} so that painting and + * the scale transform use the same numbers. + */ + private void computeThermoLayout(final Graphics2D gc, final Rectangle bounds) + { + final ScaleSpace space = measureThermoScales(gc, bounds); + final ThermoGeom geom = thermoGeometry(bounds, space); + placeThermoScales(geom, space); + + // Plot area is the bounding box of tube and bulb, the reference for scale.paint() + final double bulbBottom = geom.bulbCenterY() + geom.bulbRadius(); + plot_area.setBounds((int) Math.round(geom.centerX() - geom.bulbRadius()), + (int) Math.round(geom.tubeTop()), + (int) Math.round(geom.bulbDiameter()), + (int) Math.round(bulbBottom - geom.tubeTop())); + thermoGeom = geom; + } + + /** @return Space taken by the visible scales */ + private ScaleSpace measureThermoScales(final Graphics2D gc, final Rectangle bounds) + { + int left = 0; + int right = 0; + int top = 0; + int bottom = 0; + if (scale_visible) + { + left = scale.getDesiredPixelSize(bounds, gc); + final int[] gaps = scale.getPixelGaps(gc); // [bottom, top] + bottom = gaps[0]; + top = gaps[1]; + } + if (right_scale_visible) + { + right = right_scale.getDesiredPixelSize(bounds, gc); + final int[] gaps = right_scale.getPixelGaps(gc); + bottom = Math.max(bottom, gaps[0]); + top = Math.max(top, gaps[1]); + } + return new ScaleSpace(left, right, top, bottom); + } + + /** Size and place tube and bulb in the space left by the scales + * @param bounds Canvas area + * @param space Space reserved for the scales + * @return Thermometer geometry + */ + ThermoGeom thermoGeometry(final Rectangle bounds, final ScaleSpace space) + { + // Vertical extent, leaving room for the label overhang and the outline stroke + final int halfOutline = (border_width + 1) / 2; + final double inset = THERMO_MARGIN + halfOutline + 1.0; + final double top = bounds.y + space.top() + inset; + final double bottom = Math.max(top + 1.0, bounds.y + bounds.height - space.bottom() - inset); + final double height = bottom - top; + + // Width left for tube and bulb + final int leftSpace = space.left() > 0 ? space.left() + SCALE_GAP : 0; + final int rightSpace = space.right() > 0 ? space.right() + SCALE_GAP : 0; + final double width = Math.max(TUBE_MIN_WIDTH, + bounds.width - 2.0 * THERMO_MARGIN - leftSpace - rightSpace); + + // Tube takes half the width, capped, as in the stock thermometer + final double tubeWidth = Math.clamp(width / 2, TUBE_MIN_WIDTH, TUBE_MAX_WIDTH); + + // Bulb is wider than the tube, but must fit the remaining width and height + double bulbDiameter = tubeWidth + bulbSize; + bulbDiameter = Math.min(bulbDiameter, Math.min(width, height - TUBE_MIN_HEIGHT)); + bulbDiameter = Math.max(bulbDiameter, tubeWidth + BULB_MIN_OVERHANG); + final double bulbRadius = bulbDiameter / 2; + + // Center the assembly. On each side, the scale or the bulb reaches + // out from the tube center, whichever is wider. A widget that is + // narrower than that keeps tube and bulb in view and clips the scale. + final double leftExtent = Math.max(tubeWidth / 2 + leftSpace, bulbRadius); + final double rightExtent = Math.max(tubeWidth / 2 + rightSpace, bulbRadius); + final double centered = bounds.x + (bounds.width - leftExtent - rightExtent) / 2 + leftExtent; + final double centerX = Math.max(bounds.x + THERMO_MARGIN + bulbRadius, + Math.min(centered, bounds.x + bounds.width - THERMO_MARGIN - bulbRadius)); + + // The tube ends where its walls meet the bulb circle, never above its own top + final double bulbCenterY = bottom - bulbRadius; + final double halfChord = Math.min(tubeWidth / 2, bulbRadius - 0.001); + final double tubeBottom = Math.max(top, + bulbCenterY - Math.sqrt(bulbRadius * bulbRadius - halfChord * halfChord)); + + return new ThermoGeom(centerX, tubeWidth, top, tubeBottom, bulbCenterY, bulbRadius); + } + + /** Place the scales flush against the tube walls, spanning only the tube. + * The left scale is always positioned, even when hidden, because its + * value transform maps the liquid level and the limit lines onto the tube. */ + private void placeThermoScales(final ThermoGeom geom, final ScaleSpace space) + { + final int y = (int) Math.round(geom.tubeTop()); + final int height = Math.max(1, (int) Math.round(geom.tubeBottom() - geom.tubeTop())); + scale.setBounds(new Rectangle((int) Math.round(geom.tubeLeft() - SCALE_GAP - space.left()), + y, space.left(), height)); + if (right_scale_visible) + right_scale.setBounds(new Rectangle((int) Math.round(geom.tubeRight() + SCALE_GAP), + y, space.right(), height)); + } + + /** Draw the thermometer from the geometry of the last layout: + * empty tube, liquid, limit lines and glass outline */ + private void drawThermometer(final Graphics2D gc, final double min, final double max, final double current) + { + final ThermoGeom geom = thermoGeom; + if (geom == null) + return; + final int arc = (int) Math.max(2, geom.tubeWidth() * 0.6); + + paintEmptyTube(gc, geom, arc); + paintLiquid(gc, geom, arc, liquidLevel(geom, min, max, current)); + paintThermoLimits(gc, geom, min, max); + paintGlassOutline(gc, geom, arc); + } + + /** @return Y coordinate of the liquid surface, taken from the scale so it + * lines up with the tick marks, and clamped to the tube */ + private double liquidLevel(final ThermoGeom geom, final double min, final double max, + final double current) + { + // Like the tank, show NaN as empty. Values far outside the range + // would overflow the integer screen coordinate, so limit them first. + if (Double.isNaN(current)) + return geom.tubeBottom(); + final double inRange = Math.clamp(current, min, max); + return Math.clamp(scale.getScreenCoord(inRange), geom.tubeTop(), geom.tubeBottom()); + } + + /** Paint the tube in the empty color, shaded like the tank */ + private void paintEmptyTube(final Graphics2D gc, final ThermoGeom geom, final int arc) + { + gc.setPaint(new GradientPaint((float) geom.tubeLeft(), 0, empty, + (float) geom.centerX(), 0, empty_shadow, true)); + gc.fillRoundRect((int) Math.round(geom.tubeLeft()), (int) Math.round(geom.tubeTop()), + (int) Math.round(geom.tubeWidth()), + (int) Math.round(geom.tubeBottom() - geom.tubeTop()), + arc, arc); + } + + /** Paint the bulb, which is always full, and the liquid column up to {@code level} */ + private void paintLiquid(final Graphics2D gc, final ThermoGeom geom, final int arc, final double level) + { + gc.setPaint(new GradientPaint((float) geom.tubeLeft(), 0, fill, + (float) geom.centerX(), 0, fill_highlight, true)); + final int bulbDiameter = (int) Math.round(geom.bulbDiameter()); + gc.fillOval((int) Math.round(geom.centerX() - geom.bulbRadius()), + (int) Math.round(geom.bulbCenterY() - geom.bulbRadius()), + bulbDiameter, bulbDiameter); + if (level < geom.tubeBottom()) + gc.fillRoundRect((int) Math.round(geom.tubeLeft()), (int) Math.round(level), + (int) Math.round(geom.tubeWidth()), + (int) Math.round(geom.tubeBottom() - level) + arc, + arc, arc); + } + + /** Paint the alarm limit lines across the tube */ + private void paintThermoLimits(final Graphics2D gc, final ThermoGeom geom, + final double min, final double max) + { + if (!hasLimitLines()) + return; + gc.setStroke(limitLineStroke()); + drawThermoLimit(gc, geom, min, max, limit_lolo, limit_major_color); + drawThermoLimit(gc, geom, min, max, limit_lo, limit_minor_color); + drawThermoLimit(gc, geom, min, max, limit_hi, limit_minor_color); + drawThermoLimit(gc, geom, min, max, limit_hihi, limit_major_color); + gc.setStroke(new BasicStroke(1f)); + } + + /** Draw one limit line across the tube, placed via the scale so that it + * matches the tick marks. Limits outside the range are skipped. */ + private void drawThermoLimit(final Graphics2D gc, final ThermoGeom geom, + final double min, final double max, + final double limit, final Color color) + { + if (!Double.isFinite(limit) || limit <= min || limit >= max) + return; + final int y = scale.getScreenCoord(limit); + if (y < geom.tubeTop() || y > geom.tubeBottom()) + return; + gc.setColor(color); + gc.drawLine((int) Math.round(geom.tubeLeft()), y, (int) Math.round(geom.tubeRight()), y); + } + + /** Paint the glass outline: tube walls, rounded top and the bulb arc. + * Nothing is drawn for border width 0. */ + private void paintGlassOutline(final Graphics2D gc, final ThermoGeom geom, final int arc) + { + if (border_width <= 0) + return; + final double left = geom.tubeLeft(); + final double right = geom.tubeRight(); + final double top = geom.tubeTop(); + final double bottom = geom.tubeBottom(); + + final Path2D.Double outline = new Path2D.Double(); + outline.moveTo(left, bottom); + outline.lineTo(left, top + arc / 2.0); + outline.quadTo(left, top, left + arc / 2.0, top); + outline.lineTo(right - arc / 2.0, top); + outline.quadTo(right, top, right, top + arc / 2.0); + outline.lineTo(right, bottom); + // Around the bulb, from the right wall back to the left wall + final double dy = bottom - geom.bulbCenterY(); + final double angleRight = Math.toDegrees(Math.atan2(-dy, right - geom.centerX())); + final double angleLeft = Math.toDegrees(Math.atan2(-dy, left - geom.centerX())); + outline.append(new Arc2D.Double(geom.centerX() - geom.bulbRadius(), + geom.bulbCenterY() - geom.bulbRadius(), + geom.bulbDiameter(), geom.bulbDiameter(), + angleRight, angleLeft - angleRight - 360, Arc2D.OPEN), + true); + outline.closePath(); + + gc.setColor(foreground); + gc.setStroke(new BasicStroke(border_width)); + gc.draw(outline); + gc.setStroke(new BasicStroke(1f)); + } + /** Draw all components into image buffer */ protected Image updateImageBuffer() { @@ -758,25 +1081,38 @@ protected Image updateImageBuffer() plot_area.paint(gc); final AxisRange range = scale.getValueRange(); - final boolean normal = range.getLow() <= range.getHigh(); final double min = Math.min(range.getLow(), range.getHigh()); final double max = Math.max(range.getLow(), range.getHigh()); final double current = value; - final int level = computeFillLevel(plot_bounds.height, min, max, current, scale.isLogarithmic()); + if (thermometerStyle) + drawThermometer(gc, min, max, current); + else + drawTank(gc, plot_bounds, min, max, current, range.getLow() <= range.getHigh()); + + gc.dispose(); + + // Convert to JFX + return SwingFXUtils.toFXImage(image, null); + } + /** Draw the tank body: track, fill level, optional border and limit lines + * @param normal Range runs bottom-up? Otherwise the tank fills from the top + */ + private void drawTank(final Graphics2D gc, final Rectangle plotBounds, + final double min, final double max, final double current, + final boolean normal) + { // A widget too small for its padding and scale has no room for the // fill, so draw no body rather than one that looks empty - if (plot_bounds.width <= 0 || plot_bounds.height <= 0) - { - gc.dispose(); - return SwingFXUtils.toFXImage(image, null); - } + if (plotBounds.width <= 0 || plotBounds.height <= 0) + return; - final int arc = Math.min(plot_bounds.width, plot_bounds.height) / 10; + final int level = computeFillLevel(plotBounds.height, min, max, current, scale.isLogarithmic()); + final int arc = Math.min(plotBounds.width, plotBounds.height) / 10; // The body is the tank, or the track of a bar. The track extends into // the inner padding so that the fill sits inside it, like in the // JavaFX progress bar. - final Rectangle body = new Rectangle(plot_bounds); + final Rectangle body = new Rectangle(plotBounds); final int bodyArc; final int fillArc; if (barTrack) @@ -789,22 +1125,22 @@ protected Image updateImageBuffer() else { bodyArc = fillArc = arc; - final int center = plot_bounds.x + plot_bounds.width/2; - gc.setPaint(new GradientPaint(plot_bounds.x, 0, empty, center, 0, empty_shadow, true)); - gc.fillRoundRect(plot_bounds.x, plot_bounds.y, plot_bounds.width, plot_bounds.height, arc, arc); + final int center = plotBounds.x + plotBounds.width/2; + gc.setPaint(new GradientPaint(plotBounds.x, 0, empty, center, 0, empty_shadow, true)); + gc.fillRoundRect(plotBounds.x, plotBounds.y, plotBounds.width, plotBounds.height, arc, arc); } - gc.setPaint(fillPaint(plot_bounds)); + gc.setPaint(fillPaint(plotBounds)); if (normal) - gc.fillRoundRect(plot_bounds.x, plot_bounds.y+plot_bounds.height-level, plot_bounds.width, level, fillArc, fillArc); + gc.fillRoundRect(plotBounds.x, plotBounds.y+plotBounds.height-level, plotBounds.width, level, fillArc, fillArc); else - gc.fillRoundRect(plot_bounds.x, plot_bounds.y, plot_bounds.width, level, fillArc, fillArc); + gc.fillRoundRect(plotBounds.x, plotBounds.y, plotBounds.width, level, fillArc, fillArc); - // Optional border: stroked CENTRED on the body — no integer half-pixel - // shifting. The inner half of the stroke covers the fill edge (no gap); - // the outer half extends beyond the body into the inset margin. - // For a tank, ticks land at the body edges = centre of the border stroke, - // matching the CS-Studio BOY convention. + // Optional border: stroked CENTRED on the body. The inner half of the + // stroke covers the fill edge (no gap); the outer half extends beyond + // the body into the inset margin. For a tank, ticks land at the body + // edges, the centre of the border stroke, matching the CS-Studio BOY + // convention. if (border_width > 0) { // Java2D: fillRoundRect covers x..x+w-1, drawRoundRect strokes x..x+w. @@ -817,30 +1153,15 @@ protected Image updateImageBuffer() gc.setStroke(new BasicStroke(1f)); } - // Draw alarm / warning limit lines over the tank body - final double lim_lolo = limit_lolo; - final double lim_lo = limit_lo; - final double lim_hi = limit_hi; - final double lim_hihi = limit_hihi; - if (normal && (!Double.isNaN(lim_lolo) || !Double.isNaN(lim_lo) || - !Double.isNaN(lim_hi) || !Double.isNaN(lim_hihi))) + if (normal && hasLimitLines()) { - if (limits_from_pv) - gc.setStroke(new BasicStroke(2f)); - else - gc.setStroke(new BasicStroke(2f, BasicStroke.CAP_BUTT, - BasicStroke.JOIN_MITER, 10f, new float[]{6f, 4f}, 0f)); - drawLimitLineAt(gc, plot_bounds, min, max, lim_lolo, limit_major_color); - drawLimitLineAt(gc, plot_bounds, min, max, lim_lo, limit_minor_color); - drawLimitLineAt(gc, plot_bounds, min, max, lim_hi, limit_minor_color); - drawLimitLineAt(gc, plot_bounds, min, max, lim_hihi, limit_major_color); + gc.setStroke(limitLineStroke()); + drawLimitLineAt(gc, plotBounds, min, max, limit_lolo, limit_major_color); + drawLimitLineAt(gc, plotBounds, min, max, limit_lo, limit_minor_color); + drawLimitLineAt(gc, plotBounds, min, max, limit_hi, limit_minor_color); + drawLimitLineAt(gc, plotBounds, min, max, limit_hihi, limit_major_color); gc.setStroke(new BasicStroke(1f)); } - - gc.dispose(); - - // Convert to JFX - return SwingFXUtils.toFXImage(image, null); } /** Request a complete redraw of the plot */ diff --git a/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/RTTankTest.java b/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/RTTankTest.java index c9e5177051..d47a2f6e5f 100644 --- a/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/RTTankTest.java +++ b/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/RTTankTest.java @@ -7,6 +7,8 @@ *******************************************************************************/ package org.csstudio.javafx.rtplot; +import java.awt.Rectangle; + import org.junit.jupiter.api.Test; import org.phoebus.ui.vtype.ScaleFormat; @@ -15,6 +17,7 @@ import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; /** JUnit tests for {@link RTTank}. * @@ -52,6 +55,34 @@ public void testSetRangeRejectsInvalid() tank.setRange(Double.POSITIVE_INFINITY, 100); } + /** The thermometer layout must produce a usable geometry for any size: + * tube and bulb inside the canvas, tube above the bulb, no exception */ + @Test + public void testThermometerGeometry() + { + final RTTank tank = new RTTank(); + tank.setThermometerStyle(true); + for (int[] size : new int[][] { { 1, 1 }, { 12, 40 }, { 24, 60 }, { 30, 30 }, { 40, 160 }, { 400, 600 } }) + for (int bulb : new int[] { 0, 20, 50, 500 }) + { + tank.setBulbSize(bulb); + final Rectangle bounds = new Rectangle(0, 0, size[0], size[1]); + final RTTank.ThermoGeom geom = tank.thermoGeometry(bounds, new RTTank.ScaleSpace(30, 0, 5, 5)); + final String what = size[0] + "x" + size[1] + " bulb " + bulb; + assertTrue(geom.tubeBottom() >= geom.tubeTop(), what + ": tube ends above its top"); + assertTrue(geom.tubeWidth() >= 1, what + ": no tube"); + assertTrue(geom.bulbRadius() > geom.tubeWidth() / 2, what + ": bulb narrower than tube"); + // A canvas smaller than the minimum tube and bulb overflows, larger ones must not + if (size[0] >= 40 && size[1] >= 60) + { + assertTrue(geom.bulbCenterY() + geom.bulbRadius() <= bounds.height, what + ": bulb below the canvas"); + assertTrue(geom.centerX() - geom.bulbRadius() >= 0, what + ": bulb left of the canvas"); + assertTrue(geom.centerX() + geom.bulbRadius() <= bounds.width, what + ": bulb right of the canvas"); + assertTrue(geom.tubeTop() >= 0, what + ": tube above the canvas"); + } + } + } + /** setValue should handle NaN and Infinity */ @Test public void testSetValueEdgeCases() From a115fb195e0f85dd6b1735e688e7738782b50734 Mon Sep 17 00:00:00 2001 From: Emilio Heredia Date: Fri, 18 Sep 2026 13:50:13 -0600 Subject: [PATCH 7/7] feat(display): RTTank based Thermometer renderer, opt-in via preference Add RTThermometerRepresentation, which draws the thermometer with the RTTank engine in its thermometer style. This gives the thermometer a numeric scale with format and precision, log scale, minor ticks, an optional opposite scale, a glass outline and alarm limit lines, with the foreground color for scale and outline. The renderer is selected with the new preference org.csstudio.display.builder.representation/thermometer_scale_mode which defaults to false. With the default, the stock hand drawn ThermometerRepresentation is used and nothing changes for existing displays. The property panel hides the renderer-only properties while the stock renderer is in use. --- .../properties/PropertyPanelSection.java | 4 ++ .../widgets/BaseWidgetRepresentations.java | 4 +- .../widgets/RTThermometerRepresentation.java | 67 +++++++++++++++++++ .../builder/representation/Preferences.java | 7 ++ ...play_representation_preferences.properties | 6 ++ 5 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTThermometerRepresentation.java diff --git a/app/display/editor/src/main/java/org/csstudio/display/builder/editor/properties/PropertyPanelSection.java b/app/display/editor/src/main/java/org/csstudio/display/builder/editor/properties/PropertyPanelSection.java index fe4502af1d..4d736d8ad9 100644 --- a/app/display/editor/src/main/java/org/csstudio/display/builder/editor/properties/PropertyPanelSection.java +++ b/app/display/editor/src/main/java/org/csstudio/display/builder/editor/properties/PropertyPanelSection.java @@ -60,6 +60,7 @@ import org.csstudio.display.builder.model.properties.ScriptsWidgetProperty; import org.csstudio.display.builder.model.properties.WidgetClassProperty; import org.csstudio.display.builder.model.widgets.ProgressBarWidget; +import org.csstudio.display.builder.model.widgets.ThermometerWidget; import org.csstudio.display.builder.representation.Preferences; import org.csstudio.display.builder.representation.javafx.FilenameSupport; import org.phoebus.ui.color.NamedWidgetColor; @@ -163,6 +164,9 @@ private static boolean unusedByCurrentRenderer(final WidgetProperty property) if (property.getWidget() instanceof ProgressBarWidget) return !Preferences.progressbar_scale_mode && ProgressBarWidget.SCALE_MODE_PROPS.contains(property.getName()); + if (property.getWidget() instanceof ThermometerWidget) + return !Preferences.thermometer_scale_mode + && ThermometerWidget.SCALE_MODE_PROPS.contains(property.getName()); return false; } diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/BaseWidgetRepresentations.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/BaseWidgetRepresentations.java index 8c9927f195..fb01cc0e38 100644 --- a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/BaseWidgetRepresentations.java +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/BaseWidgetRepresentations.java @@ -124,7 +124,9 @@ public Widget createWidget() entry(TextEntryWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new TextEntryRepresentation()), entry(TextSymbolWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new TextSymbolRepresentation()), entry(TextUpdateWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new TextUpdateRepresentation()), - entry(ThermometerWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new ThermometerRepresentation()), + entry(ThermometerWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) (Preferences.thermometer_scale_mode + ? new RTThermometerRepresentation() + : new ThermometerRepresentation())), entry(Viewer3dWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new Viewer3dRepresentation()), entry(WebBrowserWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new WebBrowserRepresentation()), entry(XYPlotWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new XYPlotRepresentation()), diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTThermometerRepresentation.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTThermometerRepresentation.java new file mode 100644 index 0000000000..2e2801c88c --- /dev/null +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTThermometerRepresentation.java @@ -0,0 +1,67 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.csstudio.display.builder.representation.javafx.widgets; + +import org.csstudio.display.builder.model.widgets.ThermometerWidget; +import org.csstudio.display.builder.representation.javafx.JFXUtil; + +import javafx.scene.paint.Color; + +/** Thermometer representation based on {@link org.csstudio.javafx.rtplot.RTTank} + * + *

    The tank draws tube, bulb, liquid, scale and alarm limit lines in one + * pass, so the liquid level always lines up with the tick marks. Compared + * with the stock {@link ThermometerRepresentation} this adds a configurable + * scale (log, format, precision, minor ticks) and alarm limit lines. + * Used when the {@code thermometer_scale_mode} preference is set. + * + *

    Value, range and alarm limit handling are shared with the Tank in + * {@link RTScaledWidgetRepresentation}. This class only maps the + * thermometer's appearance properties onto the tank. + */ +public class RTThermometerRepresentation extends RTScaledWidgetRepresentation +{ + @Override + protected boolean isHorizontal() + { + return false; + } + + @Override + protected void configureTank() + { + tank.setThermometerStyle(true); + } + + @Override + protected void registerLookListeners() + { + registerScaleLookListeners(); + model_widget.propEmptyColor().addUntypedPropertyListener(lookListener); + model_widget.propBulbSize().addUntypedPropertyListener(lookListener); + } + + @Override + protected void unregisterLookListeners() + { + unregisterScaleLookListeners(); + model_widget.propEmptyColor().removePropertyListener(lookListener); + model_widget.propBulbSize().removePropertyListener(lookListener); + } + + @Override + protected void applyLookToTank() + { + applyScaleLook(); + // As in the stock thermometer, only the tube, the bulb and the scale + // are drawn, so the display shows through around them + tank.setBackground(Color.TRANSPARENT); + tank.setEmptyColor(JFXUtil.convert(model_widget.propEmptyColor().getValue())); + tank.setBulbSize(model_widget.propBulbSize().getValue()); + } +} diff --git a/app/display/representation/src/main/java/org/csstudio/display/builder/representation/Preferences.java b/app/display/representation/src/main/java/org/csstudio/display/builder/representation/Preferences.java index dad8d6b34d..f9c6acb814 100644 --- a/app/display/representation/src/main/java/org/csstudio/display/builder/representation/Preferences.java +++ b/app/display/representation/src/main/java/org/csstudio/display/builder/representation/Preferences.java @@ -29,6 +29,13 @@ public class Preferences * Requires restart to take effect. */ @Preference public static boolean progressbar_scale_mode; + /** When {@code true}, the Thermometer widget is rendered by {@link org.csstudio.javafx.rtplot.RTTank}, + * which adds a numeric scale with format and precision, an optional second + * scale, a glass outline and alarm limit lines. + * When {@code false} (default), the stock hand-drawn thermometer is used. + * Requires restart to take effect. */ + @Preference public static boolean thermometer_scale_mode; + static { AnnotatedPreferences.initialize(Preferences.class, "/display_representation_preferences.properties"); diff --git a/app/display/representation/src/main/resources/display_representation_preferences.properties b/app/display/representation/src/main/resources/display_representation_preferences.properties index e3f27e73ba..970f7ef0cd 100644 --- a/app/display/representation/src/main/resources/display_representation_preferences.properties +++ b/app/display/representation/src/main/resources/display_representation_preferences.properties @@ -51,3 +51,9 @@ embedded_timeout=5000 # When false (default), the standard JFX ProgressBar look is preserved. # Requires restart to take effect. progressbar_scale_mode = false + +# When true, the Thermometer widget uses RTTank as its rendering engine, +# adding a numeric scale, tick format/precision, an optional second scale, +# and alarm-limit lines. When false (default), the hand-drawn thermometer +# look is preserved. Requires restart to take effect. +thermometer_scale_mode = false